1. 为什么上下文工程是AI Agent从demo到上生产的生死线
1.1 AI Agent是什么,为什么上下文是骨架
做AI Agent的人现在越来越多了,但真正从demo做到能上生产、能扛住多用户同时用的项目,依然不多。我复盘了自己和身边朋友一批练手项目,发现最大的分水岭不是模型选得多强、工具链多全,而是对上下文工程(Context Engineering)的理解深不深。
先理清一个概念。AI Agent可以粗暴理解成一个“能自己决定下一步干什么”的大模型应用:它接到一个目标,自主规划、调用工具、观察结果、再调整行动,直到把任务做完。那大模型靠什么决策?靠的就是上下文。模型本身不保存任何记忆,你每一次请求给它的那堆文字,就是它感知世界的全部窗口。Agent跑得再花哨,本质上也是“读上下文→产生动作→把新结果写回上下文→再读”这个循环。
上下文工程要解决的就是这件事:你给Agent塞进去的内容,该怎么排布、怎么留存、怎么取舍、什么时候丢、什么时候压缩,才能让它既有足够信息完成任务,又不会被垃圾内容毒害。单轮聊天里,上下文就是一句prompt;但Agent场景里,上下文是一个持续演化的状态对象,包含任务背景、对话历史、工具返回、外部数据,甚至还有之前的失败尝试。骨架搭不对,后面全散架。
我自己带过不少新人,最常见的现象是“第一步能跑通就到处发,生产环境一上就翻车”。翻车原因十有八九不是模型能力不行,而是上下文处理得太粗糙:历史越积越长导致超窗,工具结果原样塞进去导致噪声,多用户并发时上下文串线导致数据张冠李戴。这些都归上下文工程管。所以别把它当成学术名词,它就是Agent能不能干活的根本。
1.2 上下文工程和提示词工程,到底差在哪
很多人会把上下文工程和提示词工程混在一起,我一开始也混淆过。简单区分:提示词工程是“一句话怎么说”,上下文工程是“一整包信息怎么管理”。提示词工程关心的是对话质量,上下文工程关心的是系统级的资源分配、生命周期和记忆策略。
举个例子。提示词工程会研究:系统提示词开头怎么写、角色设定用什么措辞、few-shot示例该放几条、输出格式怎么定义。这些当然重要,但它的作用范围基本停留在“构造一次请求”。上下文工程要管的是:这轮请求放哪些历史、哪些工具定义能精简、上轮对话的摘要怎么更新、哪些信息可以缓存、用户多了以后每个session的上下文怎么隔离。它不仅是内容问题,还是性能和成本问题。
两者的关系可以这么说:提示词工程是上下文工程的一个基础子集。提示词写得再好,如果整个上下文里塞满了重复的旧对话和无效的工具返回,模型照样跑偏。反过来,上下文结构设计得很漂亮,但提示词写得含糊不清,也不行。做Agent的时候,我习惯先把上下文当成一个数据库表来设计,再去打磨具体的prompt文案,顺序反过来的话后期返工特别多。
1.3 一个真实案例:上下文工程不到位,Agent像只有7秒记忆
我印象最深的是一个购物助手练手项目。当时用LangChain搭了一个多轮对话Agent,帮用户比较商品、给推荐,还接了一个模拟库存接口。刚开始测试单轮没问题,但多轮就翻车。
用户第一轮说“预算5000以内,屏幕好一点”,Agent推荐了A。第二轮用户说“加一条,电池续航要顶”,Agent回复时居然推荐了B,而B价格超预算。第三轮用户问“那我之前说的屏幕要求你还记得吗”,Agent直接愣住了,因为它压根没在工作记忆里保留这些约束。问题根源非常清楚:每一轮我都把全部历史原封不动塞进去,想着“模型能自己找重点”,但历史一长,模型根本分不清哪些是用户明确约束、哪些只是随口一谈。关键信息被淹没在大量对话里,就等于失忆。
还有一个数据统计Agent的案例更典型。用户问“按月份统计销售”,Agent跑完一版,用户说“不对,要把退款剔除”,Agent重新跑但忘了之前查过数据库,又把全表拉了一遍。这不光是浪费时间,更重要的是模型每次决策都像第一次听任务,根本谈不上“目标感”。后来我加了任务状态和关键事实的独立字段,效果立刻不一样。这些案例都指向同一个结论:Agent输出的可靠程度,直接由上下文管理的精细度决定。
2. 拆开上下文看看:AI Agent的记忆里到底装了哪些东西
2.1 系统提示词:Agent的“人设”加“值班守则”
系统提示词是上下文的骨架,它写的不是任务本身,而是Agent“以什么身份、按什么规则、在什么边界内工作”。很多人在这一步会犯一个毛病:系统提示词写成了长篇小说,恨不得把后台所有逻辑都讲给模型听。但系统提示词的价值不在于长,而在于可执行。
一份合格的系统提示词,我一般至少包含四块:角色定位、任务边界、输出要求、安全约束。角色定位告诉模型“你是谁、受众是谁”;任务边界明确“哪些事你做、哪些事不做”;输出要求规定格式和风格;安全约束划线,防止胡说八道或越权操作。比如一个客服Agent,系统提示词里就要写清楚“不知道的不要编,明确说需要人工介入”。
还有一个工程细节很容易忽略:系统提示词在请求中的位置。很多大模型对长上下文的注意力分布并不均匀,靠后和靠前的内容通常更受重视,中间部分容易“视而不见”。所以我会把系统提示词放在开头,把当前用户诉求放在末尾,中间放工具定义和检索内容。顺序本身也是上下文工程的一部分,排错了等于白写。
2.2 工具与函数定义:Agent的外设驱动表
Agent要干活就得调用工具,而工具长什么样,模型只能通过你提供的函数描述来理解。每个function calling的定义,本质上就是一段格式化的上下文。这里有个很现实的成本问题:工具定义非常耗token。
我见过一个Agent挂了20多个工具,每个工具描述都写了两三百字,光工具定义就吃掉一万多token。模型每次决策都必须把这堆定义完整读一遍,成本和延迟都很高。优化的思路有两个:一是精简单个工具的描述,只保留“什么时候用、参数是什么、返回什么”这三要素;二是做动态工具路由,根据当前任务阶段只加载可能用到的几个工具,而不是每轮全量注入。
工具调用返回的结果也要进上下文。但很多人直接把接口返回的原始JSON丢给模型,字段几十个,真正有用的就两三个。这种原始结果进上下文,不仅浪费token,还会让模型误把无关字段当成判断依据。我现在的习惯是:所有工具返回先过一道清洗层,只保留模型需要的字段和必要的运行状态标记,再决定要不要写回上下文。
2.3 对话历史与观测记录:别让模型“失忆”
对话历史是上下文里最容易失控的部分。它不只是用户和助手一唱一和的聊天记录,还包括Agent调用工具的动作、中间推理、错误信息。按来源分,至少有三类:用户输入、Agent输出、系统观测(工具返回、状态变更)。
一个常见的坏做法是把对话历史当作一个无限增长的列表,每次都把从第一轮到当前轮的记录全量放进去。短期窗口内还能撑住,对话一长就必然超窗,或者让模型被之前的试探性内容干扰。上下文工程的价值就在于对历史做分层:最近的原始对话保留,较早的对话转成摘要,更早的固化到关键事实表。这样模型既能记住核心约束,又不会被冗长的历史淹没。
那怎么判断哪些该保留原文、哪些该转摘要?我的经验是看“重放价值”。如果某段对话包含用户的明确决策、具体数字、终止条件,保留或抽取到关键事实里;如果只是客套话、无效追问、失败尝试,尽早让它去摘要里待着,甚至直接丢弃。
2.4 检索注入与实时数据:给Agent开一扇窗
很多Agent需要外部数据支撑,比如知识库FAQ、订单状态、天气、汇率。这些内容不以对话历史的形式出现,而是在请求时动态检索并注入上下文。这就是RAG和上下文工程的交叉地带。
注入什么、注入多少、放在哪个位置,都是上下文工程问题。我经常见到的翻车场景是:检索系统召回了一堆相关片段,开发人员图省事全塞进去,结果模型被不相关的片段带着跑。检索注入的质量比数量重要得多。我会给检索结果加三样东西:来源标识、时间戳、相关性分片说明。这样模型能区分“这是刚查到的实时数据”和“这是历史知识里的背景信息”,不会被陈旧数据带偏。
另一件容易被忽略的事:检索注入会挤占对话历史的空间,两者必须统一算预算。不能说我有个128K的窗口就可以无限塞检索结果,窗口再大,信息的信噪比一低,模型照样表现拉胯。上下文里的每一个token都应该问一句:它对当前决策有增量价值吗?没有就去掉。
3. 上下文工程的四板斧:缓存、压缩、剪枝与检索注入
3.1 先学会算账:上下文窗口是稀缺资源
在谈具体技术之前,我想先聊聊上下文窗口的成本意识。大模型声称的上下文长度,比如128K,不等于你可以随便用的128K。实际可用预算要扣掉模型输出保留空间,再留一条安全余量。
拿一个128K窗口的模型举例:每轮请求需要给模型留出回答的余地,假设设置max_tokens为4096;再留10%的余量防止工具返回特别长时截断关键信息。那实际可用的输入预算大约是128K减去输出预留和余量,大概在110K上下。如果你把system prompt、工具定义、历史、检索内容这堆东西无脑塞进去,很快你就会发现Agent会在关键时刻提示超窗,或者自己偷偷截断,导致行为怪异。
算账的另一个作用是做成本预测。上下文缓存服务通常按缓存读和缓存写分别计费,一旦你理解了每个session的输入token规模,就能判断该不该引入缓存、多长时间失效、要不要按用户粒度缓存。很多人说“AI Agent怎么扛并发”扛不住,本质往往不是框架性能不够,而是每个并发的上下文构建开销和token成本没控制住。并发量一上来,同样的历史重复构造,成本直接爆炸。
3.2 上下文缓存:把重复计算省到底
上下文缓存是上下文工程里性价比最高的一个手段,但很多人没用过。核心思路很简单:系统提示词、工具定义、企业知识库这类基本不变的内容,在每次请求之间可以复用,不用每次都让模型重新处理一遍。现在主流模型服务基本都有prompt caching能力,调用的时候会用缓存命中前缀,成本和延迟都会降一个量级。
想让缓存命中率高,关键在“前缀稳定”。模型服务一般按输入前缀匹配缓存,所以你要把稳定的静态内容放在最前面,把每次变化的动态内容放在最后面。比如system prompt、工具定义、固定示例先放;用户输入、检索结果、当前状态后放。有一回我把用户ID拼在system prompt末尾,缓存命中率掉得很厉害,改成把动态字段放到消息序列尾部,立刻恢复正常。
另外,缓存不是万能的。第一,缓存有生效延迟,第一次请求还是全量处理;第二,过期或失效后第一次请求会同时计读取和写入费用;第三,如果上下文里动态内容占比太高,缓存收益就有限。所以引入缓存之前先看静态内容占比,至少得有二三十个百分点以上的重复内容才值得上。我在项目里一般把system prompt加工具定义稳定在20K到30K之间,缓存之后多轮对话的输入成本能压到原来的三分之一以下。
3.3 上下文压缩与摘要:用信息密度换token
当对话轮数多到一定份上,压缩和摘要就是绕不开的手段。压缩不是简单地把旧消息砍掉,而是把低信息密度的内容转换成高信息密度的表示,同时保留对后续任务必需的事实。
我常用的方案是滚动摘要加关键事实双轨制。滚动摘要是一个持续更新的总结:每过N轮,把当前摘要和最近的对话一起交给模型,生成新的摘要,替换旧摘要。关键事实则是一个结构化列表,专门记录用户明确说过的偏好、决策、数字。举个例子,购物助手里摘要可能写“用户对比过A和B,还在犹豫”,关键事实里则写“预算上限5000,需要长续航”。一个是软记忆,一个是硬约束,分开存比混在一起可靠得多。
实现起来有个细节特别重要:让模型生成摘要的时候,不要让它自由发挥,要给它一个模板。比如“用户目标/关键约束/已确认信息/待确认信息/当前状态”固定格式。模板化摘要的好处是,后续往上下文里注入的时候,模型能快速定位它要的那一段,不用再大海捞针。另外,摘要生成本身也要花token,建议放到后台异步跑,不要阻塞用户请求。
但压缩和摘要是有代价的,信息丢失是必然的。正因为丢,我才会保留一个“最近对话原始区”,比如最近6到10轮对话不压缩,更早的才进摘要。既要信息密度,也要保留模型能回溯的细节,两者得平衡。
3.4 剪枝与优先级:哪些内容该被主动扔掉
压缩解决的是“太长了怎么办”,剪枝解决的是“哪些本来就是垃圾应该扔”。剪枝策略需要一套优先级规则,我自己的排序是:最新动态大于明确约束,大于高置信事实,大于试探性对话,大于过程性日志。
哪些内容属于“可直接扔”的?我的清单包括:Agent试错过程中的多次失败尝试、工具返回里的冗余字段、重复检索的相同内容、用户情绪化的废话、以及系统层面的中间日志。这些内容放进上下文,轻则浪费token,重则让模型误以为这些噪声也是决策依据。
工具调用的中间状态特别值得关注。Agent执行一个任务往往要调好几轮工具,每轮可能都有中间结果。但真正有用的往往只有最后一个结果,或者所有结果的汇总。我在实战中会设置一个“工具结果合并窗口”:同一任务阶段内的多次工具调用,先做聚合,聚合后的结果才写回上下文,原始调用过程不进对话历史。这样既保证模型能看到全貌,又不至于被过程刷屏。
3.5 检索注入与上下文工程怎么配合
RAG是上下文工程的“外设”,负责把外部知识转成可用的上下文片段。但从上下文工程的角度看,RAG需要解决三个问题:检什么、放哪、放多少。
检什么,取决于当前上下文缺什么。我在LangChain里通常先让模型根据当前对话生成一个检索意图(query),而不是直接把用户原话拿去检索。用户问“它贵不贵”,模型补全成“该商品当前售价和同价位竞品价格对比”,召回质量会明显上升。放哪,我习惯放在系统提示词之后、对话历史之前,让模型先看到“刚拿到的外部证据”,再结合历史做判断。放多少,这就回到3.1的预算问题了,我一般给检索注入划分一个动态上限,比如上下文总预算的20%以内。
还要注意时效性问题。检索结果如果带时间戳,模型会判断新鲜度;如果项目涉及实时数据,比如库存、价格,过期检索和刚抓取的数据同时在上下文里,模型可能先用旧数据,旧数据还是错的。这种情况下要明确标记检索时间,或者在剪枝时优先丢弃旧检索结果,避免脏数据自动残留。
4. 手写上下文管理器:从单轮对话到并发隔离的落地实现
4.1 先按任务类型定上下文生命周期
很多人一上来就写代码,我建议先做一道选择题:这个Agent是单轮问答、多轮聊天,还是长期任务工作流?三种类型对上下文工程的要求完全不同。
单轮问答最简单,每次请求都是独立上下文,不需要额外的记忆机制。多轮聊天需要一个session级上下文,随对话轮数增长,需要压缩和剪枝。长期任务工作流则最复杂,Agent可能挂后台跑很久,每一步的状态都要写成可恢复的上下文,中间还可能被用户打断改需求。我之前做数据报表Agent就属于这种,跑一个复杂任务要好几轮工具调用,中途用户一句“换个维度”,整个计划得重构,上下文必须支持状态迁移。
上下文生命周期可以分成四个阶段:构建、更新、压缩、释放。构建发生在会话开始时,用系统提示词加任务模板初始化;更新发生在每轮用户消息或工具返回后;压缩发生在预算逼近阈值时;释放发生在任务结束或会话过期后。这四个阶段要设计成显式的状态转移,别等代码里出现神鬼莫测的行为才回头补。
4.2 设计上下文分区与token预算表
动手写代码之前,我强烈建议先画一张token预算表。不用特别精确,但要让每个人都能说清楚每一块上下文的占比上限。下面是我常用的一张参考表,比例可以根据项目类型调整:
| 上下文分区 | 典型内容 | 建议预算占比 |
|---|---|---|
| 系统提示词区 | 角色人设、任务守则、边界 | 8%-12% |
| 工具定义区 | 函数说明、参数描述 | 10%-18% |
| 任务状态区 | 当前目标、执行计划、关键事实 | 5%-10% |
| 历史摘要区 | 滚动摘要、重要决策记录 | 10%-20% |
| 最近对话区 | 最近N轮原始对话 | 25%-35% |
| 检索注入区 | 外部知识、实时数据 | 10%-20% |
| 当前输入区 | 用户最新指令 | 动态 |
注意这张表不是死的。比如一个重度依赖知识库的客服Agent,检索注入可能要提到30%;一个纯代码生成Agent,工具定义和当前输入占比更大。关键是每个分区都有明确上限,而且所有分区加起来要低于模型可用窗口的80%左右,给输出和意外留空间。
预算表不仅指导开发,也指导测试。当我发现一个Agent某轮行为不对劲,第一件事就是抓出这轮的上下文各分区实际占用,看看哪一块超标了、哪一块被挤掉了。很多“模型抽风”的根因就是某个分区意外膨胀,把关键内容挤出了有效注意力范围。
4.3 实现一个简单的上下文管理器
代码层面,我会用一个ContextManager类把上下文的增删改查收敛在一个地方。这个类的核心接口包括:add_user_message、add_assistant_message、add_tool_result、update_state、build_request。所有外部模块不许直接操作上下文列表,都必须通过这个类暴露的方法。下面是简化后的骨架:
from dataclasses import dataclass, field from typing import List, Dict, Any import json @dataclass class ContextManager: system_prompt: str tools: List[Dict[str, Any]] budget_percent: Dict[str, float] = field(default_factory=lambda: { "history": 0.55, "retrieval": 0.15, "task": 0.10, "output": 0.20, }) recent_limit: int = 6 max_input_tokens: int = 90000 key_facts: List[str] = field(default_factory=list) history: List[Dict[str, str]] = field(default_factory=list) summary: str = "" def add_user_message(self, text: str): self.history.append({"role": "user", "content": text}) self._maybe_compact() def add_tool_result(self, tool_name: str, result: Any): cleaned = self._clean_tool_result(tool_name, result) self.history.append({"role": "tool", "content": json.dumps(cleaned, ensure_ascii=False)[:4000]}) self._maybe_compact() def _clean_tool_result(self, tool_name, result): # 按工具定义做字段白名单清洗 if tool_name == "query_inventory": return {"sku": result.get("sku"), "stock": result.get("stock")} return result def _maybe_compact(self): if self._estimate_tokens(self.history) > self.max_input_tokens * 0.6: self._roll_summary() def _roll_summary(self): old = self.history[:-self.recent_limit] if old: self.summary = self._summarize(old, self.summary) self.history = self.history[-self.recent_limit:] def _summarize(self, messages, old_summary): # 实际实现可调用LLM,这里示意模板 return f"[summary] current: {old_summary} + {len(messages)} transitions compressed" def build_request(self, user_input, retrieval=None): parts = [ {"role": "system", "content": self.system_prompt}, {"role": "system", "content": f"Key facts: {self.key_facts}"}, {"role": "system", "content": f"Summary: {self.summary}"}, ] if retrieval: parts.append({"role": "system", "content": f"Evidence: {retrieval}"}) parts.extend(self.history) parts.append({"role": "user", "content": user_input}) return {"messages": parts, "tools": self.tools}这段代码虽然简化,但把核心思想表达清楚了:历史有最近保留区,超限触发摘要,工具结果先清洗再入历史,构建请求时按固定顺序拼装。实际项目里,_summarize一定要接LLM调用,否则只做截断,信息损失会很大。
4.4 多轮对话中的上下文更新策略
多轮对话有一个隐藏难点:用户随时可能推翻之前的需求。如果你老老实实把“旧的预算5000”和“新的预算6000”都放进上下文,模型会无所适从。我用的策略是“最新意图优先”,同时在关键事实里做显式覆写。
什么意思?当识别到用户修改了某个约束时,不是把旧记录删掉,也不是原样追加,而是更新关键事实字段,并给新约束打上时间戳和“最新”标记。构建请求时,关键事实区里只保留最新版本和覆写关系,比如“预算已从5000调整为6000”。这样模型能理解变化,而不是在矛盾信息里胡乱猜。
上下文更新还有一个容易被忽略的环节:Agent自身的输出也是上下文。某些Agent在中间步骤产出了计划列表,这些计划一旦执行完,就应该从“计划区”降级到“完成记录区”,甚至被剪掉。否则每一轮都带着一份过期计划,模型以为计划还没执行,又开始重复执行。我在LangGraph里会把plan和done分开,状态更新时主动把planned置为done,这个动作本身就是上下文工程的一部分。
4.5 并发场景下的上下文隔离与共享
热词里经常有人问“AI Agent怎么杠并发”,这里先强调一个前提:并发问题的第一道坎不是进程,是上下文隔离。
Agent接入Web服务以后,每个用户请求都必须有独立的session上下文。我见过把聊天记录存在模块级全局变量里的demo,单用户测试没问题,多用户一上来,A用户的消息出现在B用户的上下文里,数据直接串线。解决思路很简单:上下文实例必须与session_id绑定,放在有生命周期的存储里,而不是全局变量。
在FastAPI这种异步框架里,我常用两种方案。简单项目用一个带锁的字典:key是session_id,value是ContextManager实例,配合TTL过期清理。正式一点的项目用Redis,序列化ContextManager或它的状态快照,用session_id作为key。注意:Redis方案里不要直接存整个messages列表,我一般存关键事实、滚动摘要、最近对话三块,取出来组装。序列化成本低,并发更新也更容易控制。
同一个session的并发更新也要处理。用户可能同时发起两个请求,如果两个请求都基于同一个旧状态做更新,后完成的会把先完成的覆盖掉。所以做状态更新时要加版本号或基于Redis的事务/锁,避免状态回退。这块很多人忽略,等到线上出现“明明点了下单,订单状态却回滚了”才来找原因,往往已经是上下文竞争问题。
如果用的是FastAPI加LangGraph,还有一个细节:别把LangGraph的运行状态放在内存全局变量里,要使用checkpointer,并且每个线程/会话用不同的thread_id。LangGraph的State本身就是上下文工程的核心载体,正确使用它的持久化能力,比自己去拼一套状态机省力得多。
4.6 一个可跑通的骨架:FastAPI + LangChain + LangGraph
结合当下热门的FastAPI加LangChain加LangGraph组合,我给一个最小可跑的上下文工程骨架思路,代码结构大致是这样:
# app.py from fastapi import FastAPI, Depends from contextlib import asynccontextmanager from agent.context import build_context_manager from agent.graph import build_graph from redis import Redis import json app = FastAPI() redis: Redis = None graph = None @asynccontextmanager async def lifespan(app): global redis, graph redis = Redis.from_url("redis://localhost:6379/0") graph = build_graph() yield redis.close() app = FastAPI(lifespan=lifespan) @app.post("/chat") async def chat(session_id: str, user_input: str): ctx = load_context(redis, session_id) # 确保上下文管理器存在 if ctx is None: ctx = build_context_manager(session_id) state = { "session_id": session_id, "ctx": ctx, "messages": ctx.history + [{"role": "user", "content": user_input}], } result = await graph.ainvoke(state, config={"configurable": {"thread_id": session_id}}) ctx.add_user_message(user_input) ctx.add_assistant_message(result["answer"]) save_context(redis, session_id, ctx) return {"answer": result["answer"]}graph里定义LangGraph的节点和状态转移,状态Schema里放ctx、messages、target这些字段。工具节点执行完,把结果写回state,同时通过ctx对象做清洗和预算控制。这一套下来,上下文工程就不再是散落在各处的临时逻辑,而是跟着Graph的状态传递走,清晰且可控。
如果你用的是Spring AI Agent,思路也一样:上下文状态用Memory接口管理,自定义Advisor来建摘要或剪枝;如果你用的是扣子这类低代码平台,上下文管理虽然被平台包了大半,但你依然要通过变量设计、节点编排来决定“哪些信息传递下去、哪些只在本节点用”。工具是表象,核心思想是通用的。
5. AI Agent上下文工程常见坑:现象、排查与修复实录
5.1 问题1:模型忽然“失忆”,前面说过的不记得
现象很好识别:用户问“我刚才说的预算你忘了吗”,Agent回“抱歉,我不记得你提过预算”。我排查这类问题一般三步走。第一步,抓取那一次请求的完整上下文快照,看看关键约束是否真的在里面;第二步,按前文的关键事实字段搜索,确认它是进了摘要还是被剪枝误删了;第三步,检查请求组装顺序,看是不是关键事实确实存在但被塞到了中间位置,模型没注意到。
修复手段通常有两个方向。一是把关键信息抽成独立字段,而不是依赖模型从原文历史里自己找,这也是我在4.3中设计key_facts的原因。二是调整上下文组装顺序,把关键事实放到system之后、对话历史之前,让模型最先看到。除此之外,检查摘要生成模板里有没有明确写“必须保留用户所有量化约束”,模板越自由,丢失风险越高。
5.2 问题2:上下文发臭,回答开始复读和绕圈
复读和绕圈,很多时候不是模型抽风,而是上下文里保留了太多次失败的中间过程。比如Agent尝试调用工具失败了一次,错误信息被完整写进历史,模型看到“刚才没成功”,于是又试一次,又失败,又写回去。几次下来,上下文里全是失败日志,模型自然只能在失败里打转。
我踩过这个坑之后,现在对工具结果进上下文有两条硬规则:第一,失败结果只保留一条精简的错误码加原因,不进完整调用日志;第二,对同一工具的连续失败,在上下文里合并成一行“工具X连续失败N次,最后一次原因:Y”,不要每轮都追加。另外,如果Agent的多步推理里出现了同一句话反复说,我通常会把前几轮Agent输出里的“思考过程”整段摘出来,只保留最终结论,思考过程不进历史。模型不需要每次重新review自己的脑回路。
5.3 问题3:Token消耗失控,账单先崩
Token超支往往不是模型太贵,而是上下文管理太懒。最典型的场景是同一个用户的长会话,每一轮都把完整历史加上完整工具定义加上完整检索结果全量送出去,token数随轮数逼近窗口上限。
我第一个动作是在请求层加一个计算器,记录每次build_request之前的token估算值,并输出到日志。用什么估算都行,简单的可以用字符数乘系数,或者直接调tokenizer。日志里按session分组,能看到每一轮的增量。一旦发现某轮增量异常大,立刻查是哪个分区膨胀了。大多数时候元凶都是某个工具返回了超长内容,或者检索模块放进来一大批低分段落。
成本控制要和压缩策略联动。我会给检索结果设置硬大小上限,比如单条不超800字符,总量不超预算占比;历史则依靠4.3的滚动摘要兜底;工具定义开启动态加载。这样一套下来,长会话token消耗能稳定控制在纯全量方案的二分之一甚至更低。
5.4 问题4:多用户并发,上下文互相串线
串线是最危险也最隐蔽的问题,因为它不一定马上暴露。用户B可能只是觉得“我明明没问过这个,怎么对话里突然多了一个不相干的问题”。排查时要先区分是存储串了还是请求串了。
存储串了,通常是全局容器里面用了没有区分度的key。比如用用户昵称当key,重名就覆盖;或者干脆把ctx挂在全局单例里,所有请求共用。修复就是引入session_id,并规范读写入口,所有上下文操作走同一层服务。请求串了,常见于异步并发场景下用了共享变量,比如在FastAPI路由里写了一个“current_session_id”全局变量,然后await期间被另一个协程改掉了。修复方案是用ContextVar绑定请求级别的上下文句柄,或者干脆把session_id作为参数显式传递,不要依赖任何隐式共享状态。
这个问题的排查成本很高,建议直接在开发早期就定好规则:任何上下文对象都不允许被全局引用,所有请求必须先解析session_id,再通过上下文服务获取实例。并发压测脚本要覆盖“不同session同时发多轮请求”这个场景,而不是只跑单用户。
5.5 问题5:工具返回的脏数据污染上下文
工具返回内容五花八门,有些接口会把调用状态码、调试信息、无关字段一起返回。如果原样进上下文,模型很容易把“HTTP 200”“requestId”当成业务内容去理解,甚至产生幻觉。
我的做法就是在工具出口处加一个清洗和校验层。清洗层负责字段白名单、长度截断、格式标准化;校验层负责判断返回数据是否满足当前任务需要。比如查库存接口返回了10个仓库的库存列表,但用户只关心指定仓库,清洗层就应该只保留目标仓库的数据。清洗之后如果内容仍然超过预设长度,再按结构压缩,比如只保留总数和TOP3明细。
注意一点:清洗层不能太激进,要保留模型判断所需的上下文。比如“库存无记录”和“库存为0”含义不同,工具如果明确返回了状态码表示数据源异常,那这个状态信息要保留给模型,不要粗暴清掉。带业务语义的错误信息也是有效上下文。
聊到这里,想再分享一个我个人的习惯。每次给Agent设计上下文工程,我都会先画一张“记忆状态迁移图”:哪些信息从哪来、经过哪些处理、最终落在哪个分区、什么时候被替换。这张图不一定给谁看,主要是逼自己把上下文当系统设计,而不是当prompt随笔写。实际做项目时,我还会在每个会话的日志里给上下文快照加一个指纹,简单算一下关键内容的哈希值,方便对比前后版本。这样就算某天模型行为回归,也能快速定位到底是上下文结构被改坏了,还是模型本身的问题。上下文工程这件事,看着是给模型做后勤,实际上是在给整个Agent做记忆架构设计。你愿意在这上面花多少心思,Agent就能在多大程度上变得真正可靠。