做AI应用的同学,十有八九都会遇到一个很实在的问题:聊着聊着,模型就忘了前面说过什么。于是各种方案冒出来,有人把历史消息无脑全塞进去,有人干脆什么都不带。前者token爆炸,后者体验断片。真正靠谱的做法,是给系统设计一套明确的 context-mode——也就是在运行时,以什么样的策略去收集、筛选、组织并注入上下文信息。这篇文章把这件事拆开讲透,你既能看懂原理,也能直接照着落地。
咱们先把受众说清楚。如果你正在做客服机器人、办公助手、编程助手,或者任何一个“需要记住用户上一句话”的产品,这篇内容都值得看完。我聊的不只是理论,更多是工程上怎么选、怎么实现、怎么排查。下面的内容会集中在 AI 会话类应用的 context-mode 设计上,其他领域如果也有类似叫法,思路同样可以迁移。
1. 先搞清楚:context-mode 到底解决什么问题
1.1 没有上下文和有了上下文,差别到底有多大
我经常用订单查询这个例子来给同事讲上下文的重要性。用户第一句问“我买的那双鞋到哪了”,第二句问“那还能退吗”。假如没有上下文,系统拿到第二句时并不知道“那”指的是“那双鞋”,只能先反问:“请问您说的是哪件商品?”多来几轮,用户就烦了。
有了上下文,系统知道当前讨论对象是那双鞋、订单状态是运输中、用户的诉求是想退。它就能直接回答:运输中的订单通常需要先签收再申请售后。
这一来一回,体现的就是 context-mode 在真实场景里的价值。它解决的不是“模型会不会说话”,而是“模型有没有足够的背景信息去理解当前这句话”。没有上下文,再强的模型也会变成一台没有记忆的复读机。
1.2 context-mode 不是调参,而是一整套取舍
很多人会把上下文问题和模型参数混为一谈,以为换个更大的模型或者调高 temperature 就能解决记忆问题。实际上,模型参数决定的是生成风格和发散程度,跟上下文是否完备完全是两回事。
context-mode 的本质,是一套关于“信息取舍”的显式规则。往大了看,它至少决定了三件事:第一,哪些信息有资格进入上下文;第二,信息以什么形态进入上下文,是原文、摘要、结构化字段还是检索片段;第三,进入之后按什么顺序排列、占多大权重。
这三件事,每一项都需要结合业务来做取舍。全量携带在短会话里没问题,但会话一长,要么超预算,要么把模型的注意力稀释掉。什么都不带又回到了“失忆版”产品。好的 context-mode,通常都落在两者之间,并且可配置、可观测。
1.3 什么场景下最需要较真地设计它
我的经验是,有三类场景最值得在 context-mode 上下重功夫。
第一类是长会话客服。用户可能跟机器人聊了40轮,涉及好几个订单和售后单,如果上下文管理不好,极容易把A订单的信息套到B订单上。第二类是编程助手,模型需要同时知道当前文件、最近的报错信息和用户刚刚改过的函数,这三者分属不同来源,必须按规则动态合并。第三类是企业知识库问答,此时不仅要有历史对话,还要有“当前检索到的文档片段”,历史信息决定了模型怎么理解问题,检索片段决定了模型拿什么来回答。
这三类场景的共性是:数据来源多样、信息量随时变化、注入错误代价高。如果只是简单拼历史文本,很快就会被各种信息淹没。
2. 拆开看:一个合格的 context-mode 要管哪几件事
2.1 上下文的采集与建模:先有数据,才有策略
很多人一上来就写过滤规则,其实第一步应该是建模。先想清楚:你的上下文里到底有哪些数据类型。
以客服助手为例,至少包含五类。用户画像,比如会员等级、常用收货地址。会话元信息,也就是session id、开始时间、当前所在流程。对话历史,包括用户消息、助手消息、中间产生的情感标签或意图标签。业务状态,比如当前正在处理的订单号、售后单状态。外部检索结果,也就是从知识库召回的相关文档片段。
把这些统一建模成结构化的上下文对象,比直接拿一个长字符串当上下文要好得多。一方面它方便程序做筛选和排序,另一方面它也方便在出问题时定位是哪个字段带错了。
2.2 挑选与过滤:不是所有历史都值得带上
这一节是 context-mode 最核心的部分:怎么从一堆候选信息中挑出真正有用的部分。
我先说一个最常见的错误,把最近20轮对话全部带上。看起来是“保留上下文”,实则很多历史信息已经跟当前问题无关了。比如用户中途查过一次物流、问过一次优惠券,然后回到售后问题,物流和优惠券的细节就是干扰项。
比较实用的做法是“时间窗口+相关性”双通道。时间窗口保证近期信息一定在场,相关性通道则从更长的历史里检索与当前意图匹配的片段。过滤规则可以包含:重复内容只保留最近一次;已经完结的流程只保留结论,不保留过程;与当前意图语义相似度低于阈值的直接丢弃。
还可以做一些加权。我给历史消息打分时,常用的公式是:语义相似度占0.6,时间新鲜度占0.3,关键槽位匹配占0.1。分数低于阈值的就不注入。这个比例不是固定的,但有了可计算的分值,总比拍脑袋强。
2.3 过期与衰减:让旧信息合理退场
上下文并不是越久越好。我见过最典型的翻车现场:用户一个订单早就取消退款了,系统还带着“订单运输中”的旧状态上下文,导致模型一本正经地建议用户“留意签收”。
这里的关键是要区分“静态历史”和“动态状态”。对话历史是静态的,它只是记录当时说了什么,可以保留;业务状态是动态的,比如订单状态、库存数量,必须以业务数据库为准。旧对话里的状态描述只是“当时快照”,不能作为当前事实。
实操上,我会给上下文里的每个信息块加一个 freshness 标记,并配合事件驱动清除。比如订单状态从“运输中”变成“已取消”,就把该订单相关的业务快照从上下文中移除,而不是等它自然衰减。固定时间衰减适合用在对话题材上,越老的语句权重越低,但业务状态必须靠生命周期管理。
2.4 budget控制与注入顺序:别让超长上下文毁掉整个prompt
上下文总量是有限资源,尤其是输入token限制。我习惯把上下文预算当成硬约束来设计:先算总量,再定分配,最后定顺序。
举个例子。假如模型输入上限是8000 token,系统提示词占1000,输出预留500,那么上下文最多只能占6500。在这6500里,我会给业务状态留1000,给用户画像留500,给对话历史留3000,给检索片段留1500,其余500作为波动余量。
注入顺序同样重要。模型往往对开头和结尾更敏感。硬约束和全局规则放最前面,检索得到的资料放中间,最新的用户意图放最后。很多人习惯把历史一股脑放在当前问题前面,偶尔可行,但一旦历史很长,模型的注意力容易被旧信息分走,导致答非所问。
3. 实战落地:给 AI 助手设计一套最小可用的 context-mode
3.1 先把上下文的 JSON 结构定下来
概念聊完了,落地要看代码。我习惯先把上下文对象设计成 JSON Schema,原因很简单:有了结构,后续做过滤、排序、序列化都有了抓手。
{ "schema_version": 1, "mode": "selective", "session": { "session_id": "sess_001", "created_at": "2025-06-01T10:00:00Z" }, "user_profile": { "level": "gold", "region": "华东", "last_seen_ts": "2025-06-01T10:32:00Z" }, "business_state": { "order_id": "ORD20250601", "order_status": "shipping" }, "history": [ { "role": "user", "content": "我买的那双鞋到哪了?", "intent": "check_order", "ts": "2025-06-01T10:31:00Z" }, { "role": "assistant", "content": "订单OR20250601正在运输中。", "ts": "2025-06-01T10:31:15Z" } ], "retrieved": [ { "source": "knowledge_base", "doc_id": "kb_return_policy", "snippet": "运输中订单不支持直接退货,需签收后申请售后。" } ] }字段的意义我就不啰嗦了,重点说两个容易被忽视的细节。history 里每条消息都带 intent 和 ts,这是后面做筛选和打分的重要输入。retrieved 单独拉出来,不混在对话历史里,是为了在组装 prompt 时能清楚地区分“模型自己说的”和“外部知识”。
3.2 让过滤规则和优先级有数可算
有了结构化数据,接下来就要定规则。我建议把规则拆成两类。
第一类是确定性规则,不需要模型参与。比如:相同问句只保留最近一次;纯表情、纯语气词不进入上下文;已完结订单相关的过程性消息只保留最终结论。这类规则简单直接,跑起来快,适合处理大量重复噪声。
第二类是模型参与的相关性过滤,比如用 embedding 给历史消息和当前用户输入算相似度,低于阈值的丢弃。分两层的设计有个明显好处:确定性规则先帮我们把体量降下来,再做相似度计算,成本和延迟都会低很多。
优先级排序我也会显式地做出来。大体顺序是:系统硬约束(不可动摇的规则,比如安全策略)优先级最高,接着是当前业务状态,然后是检索到的知识片段,再往后是近期对话历史,最后才是长期偏好类信息。这个顺序不是绝对的,但有一个原则:越不能被篡改、越不能出错的信息,越要靠前。
3.3 最小可行的伪代码实现
下面这版伪代码是我在项目里实际用过的结构,去掉了业务细节,保留了核心逻辑。
def build_context(session, current_turn, rules, budget_tokens): context = load_schema(session) # 读 JSON Schema candidates = collect_candidates(session) # 收集候选信息块 # 先跑确定性规则,快速降噪 candidates = apply_deterministic_rules(candidates, rules) # 再算相关性分数,按分数召回 scored = [] for item in candidates: score = (0.6 * semantic_sim(item, current_turn) + 0.3 * recency_score(item) + 0.1 * slot_match_score(item)) if score >= rules.score_threshold: scored.append((item, score)) scored.sort(key=lambda x: x[1], reverse=True) # 预算控制:超出就丢掉分数最低的块 total = estimate_tokens(scored) while total > budget_tokens: item, _ = scored.pop() total -= estimate_tokens([item]) # 按注入顺序重新排列 ordered = sort_for_injection(scored) return assemble_prompt(context, ordered, budget_tokens)几个细节值得说。score_threshold 通常设在0.35到0.5之间,我一般先用0.4起步,再根据线上召回效果调整。budget_tokens 是硬上限,宁可在边界上丢掉低分内容,也不能超预算导致请求失败。sort_for_injection 会单独处理排序逻辑,把最新意图放在最后,让模型把注意力集中在当前任务上。
3.4 换个思路:从“带多少”到“缺什么补什么”
调试几轮之后你会发现,按历史条数来设计上下文,很容易掉进“带得越多越安全”的陷阱。我后来慢慢调整思路:先问“当前这轮请求缺什么背景”,再倒着去搜数据。
举个例子。用户当前的问题是“那还能退吗”,系统缺的信息是“那指的是哪个订单”和“这个订单当前是否允许退”。于是程序会优先去 business_state 里拿订单状态,再去 knowledge_base 里检索“运输中订单的退换货政策”,而不是把过去20轮对话全部搬进来。
这种按需拉取的思路,既控制了长度,也提高了信息密度。实现上无非要做好两件事:一是把“缺失项”定义清楚,二是针对缺失项建好索引或检索路径。前者靠业务梳理,后者靠向量检索或结构化查询,都不算复杂。
4. 常见问题与排查技巧实录
4.1 上下文污染:聊着聊着,模型被旧信息带偏
最常遇到的问题是上下文污染。典型症状是:这轮明明是在聊新订单,模型却还在参考旧订单的物流信息,回答出来就是串台。
排查思路是先看注入的数据。我会在日志里把每轮实际发送给模型的上下文打出来,重点看两个地方:业务状态有没有被更新,旧的意图标签有没有残留在 history 里。
解决办法通常有三个。第一,业务状态字段在每次请求前都从数据库拉最新值,绝不在内存里积攒。第二,不同会话的上下文严格隔离,session_id 是硬隔离边界。第三,在关键节点主动触发 context reset,比如用户说“算了,换个问题”,就把旧流程的上下文标记为废弃。我见过很多团队忽略第三点,其实主动放弃旧上下文,往往比费力挽留更省心。
4.2 信息过时:上下文里的“事实”已经变了
上下文里最危险的不是没有信息,而是有过时信息。特别是业务状态,比如订单状态、库存数量、预约时间,一旦上下文里的信息和数据库不一致,模型就会一本正经地给出错误答案。
我自己的处理原则很简单:对话历史里提到的状态都只是“当时的说法”,绝不当作当前事实。当前事实一律以 business_state 的实时查询结果为准。在代码上,我还会给历史消息和业务状态分别打上 scope 标签,组装 prompt 时用不同前缀区分。比如业务状态前缀是“[当前状态]”,历史消息前缀是“[历史对话]”,模型看到这两种前缀,对信息可靠度的判断就会更准确。
4.3 token预算怎么算,超了怎么办
很多项目出问题,不是规则写得不好,而是预算没算明白。我建议在代码里单独写一个 token 估算函数,用模型的 tokenizer 对每个信息块做估算,而不是粗略用“字符数除以4”。前者准,后者偏差大。
预算分配建议按这张表来设计:
| 数据类型 | 分配比例 | 说明 |
|---|---|---|
| 系统硬约束 | 15% | 安全规则、输出格式要求,必须在场 |
| 业务状态 | 10% | 当前订单、用户、流程状态,优先拉最新 |
| 检索片段 | 25% | 外部知识,按相关性强弱截断 |
| 对话历史 | 40% | 时间窗口+相关性过滤后剩余的部分 |
| 余量 | 10% | 给突发情况留缓冲,避免请求失败 |
如果还超,优先丢对话历史,其次丢检索片段里的低分段。业务状态和系统硬约束基本不丢。这个降级顺序可以写进配置,让线上稳定运行时自动生效。
4.4 调试点:让上下文可观测
context-mode 最怕黑盒。我看过不少项目,上下文策略写得花团锦簇,线上效果一塌糊涂,问数据拿不出来,只能靠猜。
我的习惯是给每个请求打一条摘要日志,记录:这次注入了多少条信息、哪些来源、总 token 数、被丢弃的最具争议的信息块有哪些。不一定要全量保存内容,但至少要保存元信息,方便事后统计哪种来源的上下文对回答贡献最大。
我还会定期抽样看三类数据:历史信息的命中率(被检索到并注入的比例)、答非所问的比例、超预算后降级的比例。这三类数据能反映 context-mode 的健康度。如果答非所问比例突然上升,先去看是不是注入顺序变了;如果超预算降级比例过高,再去看是不是过滤规则阈值太低。
5. 最后分享一点个人心得
聊到最后,说点我自己的体会。context-mode 这种东西,最容易踩的坑不是技术不会,而是过度设计。我有段时间给调度任务排序加了一堆权重,代码很漂亮,结果调参比写功能还费劲。后来想明白了:大多数业务,先做好数据建模、预算控制、相关性过滤,就已经能覆盖九成问题。剩下的花哨策略,等数据告诉你确实需要再加。
还有一个私藏的小技巧:每次改动上下文策略,先做小流量对比,把“改了之后答非所问有没有下降”这类指标盯住。不要靠感觉判断,让线上数据帮你做决策。
这个思路,顺手分享给你。