☰
上下文感知AI应用实战:三类上下文注入与Prompt优化避坑指南
2026/10/7 2:15:00 网站建设 项目流程

简介:一本由AWS专家团队编写、面向CTO、机器学习从业者、应用开发者和数据工程师的生成式AI实战指南,聚焦于在AWS上构建上下文感知的多模态推理应用。内容覆盖生成式AI项目全生命周期,从用例定义、模型选型、提示工程、LoRA微调、RLHF对齐,到检索增强生成(RAG)、模型量化优化与部署,并结合Amazon Bedrock、LangChain及ReAct框架,演示如何调用外部API与私有数据源,缓解大模型知识截止与幻觉问题,支撑真实企业级业务场景。压缩包内包含1个PDF文件,共84.56MB,适合系统深入阅读;目前已有126人学习下载。书中特别阐述RAG与智能体协同的上下文感知推理架构,并通过Stable Diffusion、Flamingo/IDEFICS等案例,展示融合文本、图像、音频等多模态数据及非语言推理的落地方法;整体结构清晰,兼顾理论原理与动手实践,对希望将生成式AI实用化的开发者和架构师很有参考价值。

1. 构建上下文感知的AI应用:为什么“带记忆”只是起点

我帮不少团队做过AI应用方案评审,最常见的一个误区,是把“上下文感知”等同于“记住上一轮对话”。实际上,在大模型应用里,上下文感知指的是:在模型推理之前,把当前任务所需的历史状态、用户偏好、业务规则、环境信号组织成一份“现场简报”,用尽量少的Token让模型知道自己该基于什么做判断。它决定你的应用是死板的问答机器,还是能跟着用户真实诉求走的AI助手。这篇笔记从架构到实现、参数、踩坑,给出一条可复现的落地路径,适合正在做AI应用开发、AI智能体的程序员和技术负责人,尤其是被“模型很强但接不进业务”卡住的团队。

2. 上下文感知应用的架构:三类上下文与三条注入路径

我习惯把上下文感知应用拆成两个问题:有什么上下文?这些上下文该走哪条路进模型?前者是数据建模,后者是推理时的数据流设计。把这两件事想清楚再写代码,后面才不会变成“在Prompt里堆字段”。

2.1 短期上下文:多轮会话里的状态窗口

短期上下文是“当前任务进行中产生的状态”,包括:用户最近几轮说过什么、已经选择的条件、智能体执行到哪一步、当前停留在哪个页面或表单。它变化最快,也最容易被简单粗暴地“全量塞进Prompt”。

我一般会把短期上下文做成结构化状态,而不是截取原始聊天记录。比如客服场景里,用户说“我上个订单能不能改地址”,短期上下文需要的不是完整对话,而是已经解析出的order_id、当前动作是change_address、以及“是否已确认新地址”这个状态。如果你把几轮原始文本全丢给模型,它要从里面自己找订单号,往往找错。

{ "session_id": "s_12345", "user_id": "u_88", "current_scene": "order_change_address", "slots": { "order_id": "A123", "target_address": "xxx", "confirmed": true }, "action_queue": ["check_order_owner", "lock_order"] }

这个action_queue特别容易漏。做AI智能体时,模型需要知道“刚才已经把库存锁了”,如果这步动作只被当作对话内容,模型下次可能又去锁一遍。把动作单独保存,组装上下文时会提醒模型“这些动作已完成”。

2.2 长期上下文:用户画像与业务档案的存取

长期上下文是跨会话稳定存在的用户级信息:用户ID、偏好、权限、会员等级、历史订单记录。有些团队把“上一轮说喜欢”存进短期会话历史,下一次新会话就拿不到了。正确做法是从独立存储中按用户ID读取,而不是靠聊天记录里找。

我的选型原则是:能查的别记忆,能结构化别用自然语言。用户地址、订阅状态、库存数量都该从数据库或API动态查询,而不是让模型“记住”。模型记忆只适合低频、模糊、无权威来源的偏好,例如“用户偏向价格便宜的方案”,这类用标签或JSON存到画像表即可。

{ "user_id": "u_88", "preferences": {"coffee_size": "large", "limit_spicy": true}, "tags": ["vip", "new_here"], "updated_at": 1780000000 }

长期上下文必须带版本和更新时间。它比短期上下文更容易出现“新值覆盖旧值”的问题。我会在每条偏好字段上带updated_at和source,source标记是用户自己说、行为推断、还是管理员配置,后续做时效判断和纠偏都靠它。

2.3 环境与业务上下文:从“读接口”到“动态Schema”

环境上下文来自应用外部,例如天气、库存、汇率、订单状态。调用接口后拿到的是原始JSON,直接丢给模型既浪费Token又制造噪声。常见做法是写“环境适配器”:每个API对应一个清洗函数,只抽当前场景真正需要的字段。

比如用户问“订单什么时候到”,适配器只返回{order_status: "shipped", expected_delivery: "2026-03-02", carrier: "SF"},而不是返回整张物流表。动态Schema的意思是入参结构不固定:不同场景需要不同环境上下文。所以适配器要注册成字典,场景名 -> 适配函数,先做意图粗分类,再调用对应函数。

不要每个请求把所有外部接口都调一遍,否则延迟和成本都失控。多模态大模型进展很快,环境上下文也不只限于文本,比如上传的图片、录制的语音片段,同样能通过多模态接口注入,但清洗逻辑依然要做:先抽取关键帧或转写文本,再决定要不要进模型。

2.4 三条注入路径:直接进Prompt、RAG召回、工具绑定

三类上下文怎么送给模型?我在实践里只用三条路径,按上下文特性选择,路径选错了,再好的数据也白搭。

注入路径适合的上下文优点成本与注意点
直接进Prompt短期状态里的关键约束、用户画像标签、环境摘要可控性最强,立即生效占Token;太多会稀释注意力
RAG召回业务文档、知识库、历史聊天片段海量信息也能覆盖需要向量化和重排,存在召回噪声
工具绑定需要实时决策、会改变系统状态的动作上下文上下文只在需要时注入模型可能调错参数,需要校验动作队列

选择步骤:先看这个上下文是否足够小且关键,是就进Prompt;再看它是否需要实时获取、是否会对系统产生副作用,是就走工具绑定;如果是静态大库、无法穷举的,走RAG召回。

我见过最典型的错误是把所有东西都塞进Prompt:用户画像、20轮对话、10条检索一起放,模型反而抓不住“用户问的是什么”。所以第4章专门讲怎么给上下文定预算和优先级。

3. 搭建最小可复现的上下文感知流水线:从零实现一个带记忆和用户偏好的AI应用

这套代码在我的项目里拆过很多次,剥掉业务后大概是“会话记忆 + 用户画像 + 业务检索 + 组装Prompt”四段。下面每一段都能独立替换成你用的模型接口和数据库。

3.1 定义上下文数据结构

先明确每个对象的字段,字段设计决定了后续代码怎么写。

from dataclasses import dataclass, field from typing import List, Dict @dataclass class ChatMessage: role: str # user / assistant / tool text: str ts: float # 消息时间戳 @dataclass class SessionContext: session_id: str user_id: str messages: List[ChatMessage] = field(default_factory=list) current_page: str = "" # 当前业务场景标识 action_queue: List[str] = field(default_factory=list) # 已完成动作ID @dataclass class UserProfile: user_id: str name: str preferences: Dict[str, str] = field(default_factory=dict) tags: List[str] = field(default_factory=list) recent_orders: List[Dict] = field(default_factory=list) updated_at: float = 0.0

逻辑说明:action_queue是容易被忽略的字段。AI Agent执行“锁库存”这类动作后,如果动作结果只存在于对话文本里,下次推理无法可靠识别,可能重复执行。把动作ID单独放队列,组装上下文时系统会明确知道“哪些动作已完成”。preferences用字典而不是自由文本,是为了在代码里做硬性匹配,比如“用户忌口辣”不等于“用户不吃火锅”。

参数说明:role限定三种角色,避免混入系统消息;current_page用于场景化适配环境上下文;updated_at在长期上下文中做版本判断,没有它就会出现“读到旧数据”。

3.2 短期记忆存取:用Redis存最近N轮

这里突出两个动作:保存时滚动截断,读取时再截断一次,双重保证Prompt不会长大。

import json, time import redis r = redis.Redis(host="localhost", port=6379, db=0) def save_message(session_id: str, role: str, text: str, max_turns: int = 10) -> None: key = f"session:{session_id}:messages" msg = {"role": role, "text": text, "ts": time.time()} raw = r.get(key) msgs = json.loads(raw) if raw else [] msgs.append(msg) # 只保留最近 max_turns 轮,防止列表无限增长 r.set(key, json.dumps(msgs[-max_turns:], ensure_ascii=False)) def load_short_context(session_id: str, max_turns: int = 8) -> List[ChatMessage]: raw = r.get(f"session:{session_id}:messages") if not raw: return [] msgs = json.loads(raw) # 读取时也截断,留出系统消息和用户输入的预算 return [ChatMessage(**m) for m in msgs[-max_turns:]]

逻辑说明:用Redis里的JSON字符串存消息列表,核心是“截断”。保存时截到10轮,读取时只取最近8轮,保证历史占用的Token有一个硬上限。用Redis而不是内存列表,是为了多副本服务共享会话状态;单机玩可以换成字典,思路一样。

参数说明:max_turns的单位是“轮”,不是“条数”。一条用户消息加一条助手回复算一轮,如果你用条数截断,很多应用会把回复和问题拆散。生产环境中要给Redis key加TTL,比如30分钟,避免半截会话残留下一次被用到。

3.3 长期画像读取:别再用历史消息拼用户偏好

长期上下文要按用户ID结构化读取,绝不能靠“从对话历史里捞”。

import psycopg2, json def load_user_profile(user_id: str) -> UserProfile: conn = psycopg2.connect("host=localhost port=5432 dbname=ai_app user=app password=app") cur = conn.cursor() cur.execute(""" SELECT user_id, name, preferences, tags, updated_at FROM user_profile WHERE user_id = %s """, (user_id,)) row = cur.fetchone() if row is None: return None return UserProfile( user_id=row[0], name=row[1], preferences=json.loads(row[2]), tags=row[3], updated_at=row[4], )

逻辑说明:用户上次说“我喜欢大杯”,这句话在会话里只是文本。下一次新会话开始,历史已经不在上下文里,所以需要有一条链路把关键偏好写到user_profile表,让每个新会话都能读到。这段代码只负责读,写入口需要在对话结束或偏好发生变化时触发。

参数说明:preferences用JSON类型存储,允许不同用户有不同的键;tags做粗粒度匹配。生产环境要换成连接池,并且用with或finally关闭连接,我这里省略了关闭动作,实际跑需要补上。

3.4 业务知识召回:带阈值的向量检索

从业务文档、规则库里找相关信息,这一步负责把“大而全”的知识缩成几条候选。

from sentence_transformers import SentenceTransformer import numpy as np model = SentenceTransformer("BAAI/bge-large-zh-v1.5") # global_chunks: 离线切分好的文档片段, 每个元素是 {"text":..., "metadata": {...}} # index: 已加载的 faiss 向量索引 def recall_business_chunks(query: str, top_k: int = 4, min_score: float = 0.45) -> List[Dict]: q_vec = model.encode([query], normalize_embeddings=True) scores, idxs = index.search(q_vec, top_k) hits = [] for score, idx in zip(scores[0], idxs[0]): if score < min_score: # 低于阈值的上下文宁可不要 continue hits.append({**global_chunks[idx], "score": float(score)}) return hits

逻辑说明:返回的是“候选业务上下文”,不是直接进Prompt。保留score字段,后面重排和调试时可用。min_score是防噪声的底线,避免明明没有相关内容却硬塞几条进来,导致模型顺着噪声编造。

参数说明:top_k根据Prompt预算动态传值,默认4比较稳妥。min_score=0.45是中文通用段落Embedding的经验值,换模型后需要重新统计。如果score普遍偏高,比如所有候选都在0.7以上,说明文档和查询属于同一批高频词,阈值要相应调高。

3.5 组装最终Prompt:只放必要上下文

这一步把之前的数据拼装起来,规则是“系统消息放规则和事实,历史消息放短期状态,最后放最新输入”。

def build_messages(session_id: str, user_id: str, user_input: str) -> List[Dict]: short_msgs = load_short_context(session_id, max_turns=8) profile = load_user_profile(user_id) hits = recall_business_chunks(user_input, top_k=3) profile_text = f"用户偏好: {profile.preferences};标签: {profile.tags}" if profile else "" business_text = "\n".join( [f"[{h['metadata'].get('page_id','')}] {h['text']}" for h in hits] ) scene = "客服咨询" # 由意图分类环节给出,这里省略 system = ( f"你是AI客服助手。当前场景:{scene}。\n" f"{profile_text}\n" f"业务资料(仅参考,冲突时以用户表述为准):\n{business_text}" ) messages = [{"role": "system", "content": system}] # 历史消息跟在 system 后面,作为短期上下文 for m in short_msgs: messages.append({"role": m.role, "content": m.text}) messages.append({"role": "user", "content": user_input}) return messages

逻辑说明:顺序是system放“规则和事实”,历史放中间,用户最新输入放最后。大多数模型对Prompt的阅读习惯是:system优先级最高,最新输入是直接问题。business_text限制在3条以内,避免上下文膨胀。

参数说明:profile_text为空时不要拼出“用户偏好: {}”这种空壳,直接省略。scene由粗分类求得,没做意图分类时可以写“通用问答”,但上下文感知的效果会打折。top_k=3配合min_score=0.45,实际进入Prompt的业务片段一般2条左右,这就是“宁可少但准”。

4. 上下文的取舍与调度:窗口管理、重排序与持久化策略

第3章的流水线能跑通,但离“好用”还有距离。上下文感知的真正难点是:每类上下文都在争夺有限的窗口,不是每个都值得同样的预算。这一章讲预算、重排、更新和优先级。

4.1 Prompt窗口与Token预算:先算账再拼装

8K窗口别真用到8000,常规控制在6K以内,留出模型输出空间。我给每类上下文分配固定预算,拼装前先算账,超限就按策略压缩。

上下文类型默认预算(Token)超限策略
系统指令+场景300 - 600精简措辞,禁止堆规则
实时环境快照200 - 400只放清洗后的字段
用户画像200 - 300只放当前场景相关偏好键
短期历史1000 - 2000滚动截断或摘要压缩
业务检索片段800 - 1500重排后只取2-3条
用户最新输入200 - 500不截断,原样保留

估算Token可以用tiktoken:

import tiktoken enc = tiktoken.get_encoding("cl100k_base") def estimate_tokens(text: str) -> int: return len(enc.encode(text))

逻辑说明:先对每条上下文调用estimate_tokens,再把用户输入保留,其它按表格里的预算比例压缩。压缩不是简单截断,而是转成规则摘要:能说“用户忌口:不辣”,就不要贴原对话“我不太能吃辣,尤其是四川火锅那种辣”。

参数说明:不同模型的分词器不一样,cl100k_base适用于大多数OpenAI兼容接口。预算表只是启动值,上线后根据日志调整。如果发现某次请求反复被截断,优先检查是不是短期历史占用过高。

4.2 检索重排序:别让Top-K吞掉窗口

向量召回按Embedding相似度排序,但“字面相似”不等于“语义可用”。我会再加一道Cross-Encoder重排,用模型交叉编码判断“这对文本是否在说同一件事”。

from FlagEmbedding import FlagReranker reranker = FlagReranker("BAAI/bge-reranker-base") def rerank(query: str, candidates: List[Dict], top_n: int = 2) -> List[Dict]: pairs = [(query, c["text"]) for c in candidates] scores = reranker.compute_score(pairs, normalize=True) scored = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True) return [c for c, s in scored[:top_n] if s > 0.35]

逻辑说明:重排比Embedding匹配更懂“是不是同一件事”。top_n=2加阈值0.35是经验值,具体要看你重排得分的分布。如果阈值过低,上下文里会出现“相关话题但不是用户要的答案”,比没有更糟。

参数说明:重排模型要给每一对文本过一遍交叉编码,所以先粗召回20条,重排后只留2条,避免计算浪费。这里的flag_embedding库需要单独安装,如果你不方便引入新依赖,也可以用llm调用一次query+doc文本让模型打分,但延迟会高很多。

4.3 长期上下文的刷新与过期:画像不是只读缓存

长期上下文如果一存了之,迟早变成错误记忆。用户改了口味、换了门店,上下文却还在用旧值。我给画像字段做“版本化”。

CREATE TABLE user_preference ( user_id varchar(32), pref_key varchar(64), pref_value text, source varchar(16), -- user_input / infer / admin updated_at timestamptz, version int );

每次更新都插入新行,读取时取version最大且updated_at不晚于当前请求时间的记录。这样能处理“同一轮里先改偏好后提问”的场景:提问看到的应该是更新后的值。具体实现上,在一次对话的开始时做版本快照,组装Prompt只读快照,避免并发写入把上下文读脏。

4.4 上下文调度优先级:谁先被裁剪

当预算不够时,必须给上下文定淘汰顺序。我把优先级按“对当前决策的影响强度”排:

  1. 模型必须执行的当前动作与用户最新输入:不裁
  2. 刚发生的、影响后续动作的历史,如“用户已确认订单”
  3. 实时环境快照,必须从API获取的上下文
  4. 用户画像里和当前场景强相关的键
  5. 更早的会话摘要
  6. 通用知识库检索片段

实现上写一个调度函数,输入“请求上下文集合、Token预算、场景”,输出“该裁剪的上下文类型”。让程序自动裁,而不是每次手调。比如预算不够时,先把通用检索片段从3条减到1条,再把旧会话摘要压缩,最后才动环境快照。顺序反了,模型就会丢失关键事实。

我见过不少项目在4K窗口里硬塞20轮历史、8条检索、完整画像,最后答非所问。做AI应用开发时,把调度规则当成和Prompt一样重要的资产,上线后每周根据日志调一次。

5. 上下文感知应用的避坑指南:5个高频翻车场景与排查方法

任何上下文系统都要经历“原型能跑、上线翻车”的过程。我接入团队时最常见的问题集中在五个地方,每条按现象、原因、解决展开。

5.1 上下文越长,模型越看不见关键信息

现象:把上下文从2轮加到10轮后,准确率反而下降。用户开场说“不要辣的”,问答环节推荐的全是辣菜。

原因:注意力被长文本稀释。模型对中段上下文记忆最弱,把历史全部以原始文本塞进去,关键约束会被淹没。

解决:不要只截断,要做信息项复制。会话开始时解析一次用户输入,把“确定性约束”抽成槽位存进SessionContext.constraints,并在系统消息里重复。比如“用户忌口:不辣”作为独立事实放进system段,同时保留历史里的原始说法。这样即使历史被截断,约束也不会丢。

5.2 会话漂移:短期记忆读到了别人的数据

现象:用户反馈“打开页面后看到上一个客户的名字和订单”。这是信息安全事故,比回答错误严重得多。

原因:前端生成了session_id但没与登录用户绑定,用户刷新页面、共享设备、或服务端复用了已存在的Redis key时,上下文串了。

解决:所有读写短期上下文的接口都要求传入user_id,Redis key用session:{user_id}:{session_id},读取前先检查r.get(f"session:{session_id}:owner") == user_id。给Redis key加EX=1800,避免长期残留导致后续复用。排查时先看这类key是否出现在不符合约定的用户请求里。

5.3 检索结果“看着相关但就是没用”

现象:RAG召回的chunk和用户问题余弦相似度很高,但回答仍然错误,像是把另一个类似订单的规则套进来了。

原因:Embedding匹配的是语义相似,不是实体和状态一致。用户问“A订单能否取消”,召回了“B订单取消规则”,相似度高但不是同一条订单。

解决:在chunk的metadata里带上实体ID、业务类型、有效时间。召回到的片段如果entity_id与当前请求不一致,直接过滤。硬过滤优先于重排:先按字段过滤,再进重排,让模型看到的一定是匹配当前实体的规则。我常在这个环节挂一串规则,比如“申请单模糊匹配时,必须包含订单号前缀”。

5.4 长期上下文写偏或读旧:刚改的偏好不生效

现象:用户上午在设置页把“默认门店”改了,下午客服机器人还在推荐旧门店;半小时后好了,但新版本又回退。

原因:画像更新接口和推理链路没有统一读同一个数据源。可能是缓存TTL设成1小时,也可能更新时写到了新行但读取时按旧主键,多个服务各写各的导致脏写。

解决:画像更新走单写者,统一通过一个偏好服务更新和读取。缓存只做二级,TTL压到1分钟以内。更新时写入updated_at=now(),组装上下文时校验偏好数据的updated_at不晚于当前请求时间,防止读到未来数据。另外在请求日志中记录画像的version和updated_at,回退时能回溯。

5.5 上下文里带出敏感字段:日志比模型更早泄露

现象:排查线上问题时,发现日志里打印了组装前后的完整Prompt,里面包含用户手机号、身份证和家庭地址。

原因:上下文组装层的日志直接记录了messages变量,包含所有从画像和业务库里读到的字段,没有脱敏。

解决:日志分两层。组装层只记录“上下文摘要”,例如context_used="profile(preference:spicy), order_status, history_turns=6",不记录实体值。需要完整采集时,把脱敏函数挂在进入Prompt之前的调用点,手机号、ID card等字段先做mask:保留后4位,其余用星号。上线前加一道代码扫描,校验匹配敏感正则的字段不会出现在日志变量中。

6. 把上下文感知做成能用且可评估的AI应用:从日志到指标的一个完整闭环

没有评估,上下文感知就是玄学。我常用的验证方法叫“上下文敏感性测试”:固定同一个用户输入,分别用“带正确上下文、无上下文、带错误上下文”跑三次,看输出变化。如果三版答案几乎一样,说明上下文根本没被模型利用,问题不在Prompt长度,而在组装方式。做法是给请求加trace_id,把每次组装的context_used写进日志,抽样统计正确上下文命中率。

另一个技巧是只动一个变量做回归。比如把检索片段数从5降到2,同一批测试集打分升了还是降了;把用户偏好键从全部改成只留3个,回答准确率怎么变。我给上下文感知项目加了两个核心指标:上下文利用率(模型输出里实际引用了多少注入事实)和上下文噪声率(注入后被模型忽略或误用的比例)。前者用LLM-as-Judge逐条打分,后者靠人工抽样。

我的习惯是每次上线新版本前,先做一轮“去掉某个上下文会不会翻车”的对照测试。有一次我发现业务检索片段召回了大量相似文档,模型反而回避了确定性规则,就是因为召回阈值太低。后来把top_k从5调到2,加重排阈值,准确率升了11个百分点。这种改进比换模型见效快。

上下文感知不是一次性工程,它更像给AI应用做持续调参。每加一条新上下文,都回答三个问题:它是否真的影响这次判断?去掉它用户发现得了吗?它会不会和已有上下文冲突?把这套审视习惯带进团队,比任何框架都重要。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询