1. 单轮对话的胜利经验,是怎么变成Agent路上的坑的
1.1 过去"提示词万能"的错觉是怎么形成的
我有很长一段时间坚信:大模型应用效果不行,九成是指令写得不够好。这个信念不是我拍脑袋得来的,而是被实战喂出来的。早几年做文本改写、内容抽取这类功能时,同一个模型接口,我把提示词从"帮忙改一下"细化成"你是资深编辑,请保持原文信息密度,压缩到300字以内,不改变数字和结论"之后,输出质量肉眼可见地上了一个台阶。那时候团队里谁遇到模型产出不对,第一反应就是"找我调prompt"。
我后来想明白了为什么提示词在那些任务里那么管用。单轮任务的上下文极其简单,模型每次看到的输入基本就是任务描述加一条用户输入,没有历史包袱,没有工具结果,没有外部数据。这种情况下,提示词承载了几乎全部的"智力负担",你把它写清楚,模型需要的所有约束和知识就都齐了。一个实习生拿到一页纸的操作手册就能把手头的活儿干好,因为他只需要面对这一件事。
用赌桌来类比更直白:单轮任务是你手里只有五张牌,你的策略好不好完全取决于这五张牌是什么;提示词就是牌面。所以大家拼命研究怎么出牌,这没有错。可到了Agent场景,牌面突然变成了几十张,里面还混着别人打过的废牌,这时候你再研究"出牌顺序"已经不够了,先得弄清楚手里到底哪些牌是自己的。
1.2 Agent每次调用看到的其实是"一锅乱炖"
刚开始做Agent时,我想当然地认为:Agent也就是聊天,把对话历史传进去,再写好system prompt就行。真正上手后我才发现,模型每次生成回答时看到的上下文,根本不是"用户说了一句什么",而是一大坨由多个来源拼起来的混合体。常见结构大概是这样的:
messages = [ {"role": "system", "content": "你是客服助手,请只回答用户当前问题..."}, {"role": "user", "content": "我要查订单A"}, {"role": "assistant", "content": None, "tool_calls": [...]}, {"role": "tool", "content": "{'order_id': 'A123', 'status': 'shipped', 'items': [...]}"}, {"role": "user", "content": "A现在到哪了"}, {"role": "assistant", "content": "订单A已发货,预计明天到达..."}, {"role": "user", "content": "那B呢?"}, ]中间那串tool结果就是工具返回的原始数据,可能上千个token,里面带着订单详情、商品明细、金额、地址。模型在看到"那B呢?"这句话时,它的处境像一个员工面前摊着一堆文件夹,却没有人告诉他"现在要处理的是B文件夹"。你可以在system里写一百遍"请专注于当前用户问题",但上下文中间那段关于订单A的详细JSON实在太醒目了,模型很容易被带着走,因为它本质上是一个做条件生成的系统,上下文里什么占权重高,它就更容易顺着什么往下说。
这个发现让我意识到:提示词只是上下文里的一行文字,它要跟其他所有内容竞争模型的注意力。Agent系统里"模型看到了什么"远比"模型被告知了什么"重要。
1.3 我的第一个翻车现场:提示词全对,模型照样串台
具体说说我第一次翻车吧。当时做一个客服工单Agent,负责查订单、处理退换、转人工,我给系统提示词写得特别细致:角色定义、禁止事项、回答步骤、语气要求,一条不缺。上线跑了一周,低并发测试看着还行,一旦用户多聊几轮,问题就出来了。
有个印象很深的case:用户先问订单A的发货状态,模型回答"已发货";接着用户又问订单B,模型回答"已签收";第三轮用户说"那这个能不能退"。模型没有明确说这是在问A还是B,而是结合上下文里最近出现的高权重信息,给出了一个模棱两可的答案,把订单B的签收状态和订单A的退款政策混在一起。用户当然不满意。
我一开始固执地认为是提示词约束不够,连夜加了很多句"请严格区分不同订单""请先确认用户所指对象"。改完上线,错误率几乎没动。后来我把每次请求真正发给模型的那一长串内容完整导出,自己逐字读了一遍,才看清问题:第二轮工具返回的订单B完整JSON一直留在上下文里,第三轮开始时根本没有被清理,也没有任何标记告诉模型"这个JSON是历史查询结果,不是当前用户提到的事实"。模型被这份陈旧但具体的"事实"带跑了。
那次之后我把所有prompt恢复原样,只改了上下文构造逻辑——在每次工具调用后给结果做摘要、打上时间标记,并用一个结构化字段记录"当前查询对象"。同样的提示词,错误率立刻降下来。也是从那时候起,我认定问题不是提示词不行,是上下文没做对。
2. "上下文没做对"的三种典型病:污染、缺位、错序
2.1 上下文污染:旧工具结果成了"假事实"
第一种病是上下文污染。模型对上下文里的文本几乎是一视同仁的,它不会自己判断"这句话是昨天的参考,不是今天的任务"。只要一段信息以事实陈述的形式出现在上下文里,模型就会倾向于把它当作当前任务的事实来用。这跟我们看会议纪要一样——如果桌面上同时放着昨天和今天的会议记录,而且都没有标注日期,你很容易拿昨天的决议当天的事。
Agent系统里最常见的污染源就是工具结果和外部检索片段。比如客服场景,用户先查询了订单A,工具返回了一长串JSON,这个JSON被原封不动留在messages里。到了下一轮,用户的问题明明是关于订单B的,但模型眼前仍然飘着订单A的"status": "shipped"这类字眼。它并不会因为你system prompt里写了"请基于当前问题回答"就自动忽略这些内容。
排查污染的办法很笨但很有效:把每次请求的完整上下文按来源打上标签,然后逐条问自己"这条信息对模型回答当前问题有没有必要"。没有必要的,就是污染。我后来定了一个原则:上下文里每一个信息块,都必须有存在的理由,说不出来理由的就不放。
2.2 上下文缺位:关键前提被截断,模型只能脑补
第二种病正好相反,该在的信息没在。最常见的是窗口一超就按token长度从后往前截断。很多系统的实现逻辑很简单:消息太多就丢掉最旧的那些。可是最早的system指令和用户第一轮说的任务目标,往往是整个对话的前提。你把前提扔了,后面所有话就变成了无根之木。
举个例子。用户第一轮说"我要对比三款手机,分别叫A、B、C,请从续航、拍照、价格三个维度分析",接下来几轮用户简说"第一个怎么样""那另一个呢""最后一个呢"。如果第一轮因为token超限被截掉了,模型看着"那另一个呢"根本不知道"另一个"是什么,但它不会承认自己不知道,它会在当前上下文里找最像的实体,然后编一个答案出来。这就是Agent里"脑补式幻觉"的重要来源——不是模型天生爱编,是它真的缺少回答问题所需的前提,又没人允许它说"我不知道"。
除了截断,还有一种缺位很隐蔽:工具结果虽然放回了上下文,但对应的查询参数没有一起放回。模型看到一份查询结果,却不知道这个结果是针对哪个实体查出来的,下一轮需要指代时就只能瞎猜。正确的做法是让上下文里永远保留"这个结果的查询条件是什么"。
2.3 上下文错序:信息都有,但位置不对,注意力就是看不见
第三种病是错序。信息都在上下文里,但摆放的位置不对,模型就是"看不见"。大模型的注意力在长文本上并不是均匀分布的,开头和结尾附近的区域权重往往更高,中间一大段很容易被稀释。这不是我拍脑袋说的,你随便找个长文档任务跑一跑就能感觉到:核心指令放在开头比埋在正文中间有效得多。
我自己就踩过这个坑。做某知识库Agent的时候,我喜欢把检索回来的资料直接append到messages末尾,想着"离得近模型记得住"。结果发现模型经常被末尾那段长资料带跑,把参考资料当成用户指令,或者被资料里的语气影响输出风格。后来我把检索结果用明确的"参考资料"块包裹起来,放在任务指令之后、当前用户问题之前的固定位置,效果立刻改善。
错序还有一种表现是互相矛盾的信息并存。比如system里写了"只能用中文回答",结果某个嵌入的上下文片段里有一句"Please reply in English"。这种矛盾同时存在时,模型往往会被后面的覆盖,因为后出现的信息在局部范围里更"新鲜"。所以上下文不只是一个顺序问题,更是一个结构问题:你需要像布置工作台一样,把当前任务要用的材料放在看得见的位置,把与任务无关的杂物清走。
这三种病我用一张表总结过:
| 病症 | 典型表现 | 后果 | 排查方法 |
|---|---|---|---|
| 污染 | 旧工具结果、旧检索片段长期留在上下文 | 模型把历史当现状,回答串台 | 按来源打标签,逐条质问必要性 |
| 缺位 | 任务前提被截断、查询参数没有回流 | 模型脑补缺失信息,产生幻觉 | 列出当前问题的最小事实集,逐一核对 |
| 错序 | 核心指令被埋在中间、参考资料混入用户消息 | 模型看不见关键约束,注意力被带偏 | 固定信息块位置,用标签做结构 |
3. 把上下文当状态管理:分区、结构化与生命周期
3.1 上下文分区:固定区、工作区、历史区各司其职
想通了问题之后,我开始把上下文当成一套有结构的状态系统来设计,而不是简单的一堆消息记录。我现在构造上下文的逻辑是三个区。
固定区放系统级指令、全局安全约束、用户身份事实。这部分内容稳定,几乎不随对话轮次变化,token占比很小,却决定了整个Agent的行为边界。工作区放当前任务的所有相关事实,包括当前用户问题的解析结果、本次任务涉及的实体、最近真正必要的几轮对话、工具结果的有效摘要。历史区放过往对话的压缩记忆或检索条目,一般不直接堆原始消息,等需要时再拉进来。
关键认知在这里:工作区的目标不是完整记录历史,而是为当前决策凑齐最小充分事实集。这跟人工作一模一样,你不可能在开会的每个瞬间都在脑内完整回放所有相关会议,你只会拿起与当前议题相关的那份材料。
我后来用代码层面的ContextBuilder来保证这一点,结构类似这样:
class ContextBuilder: def __init__(self, system_prompt, global_constraints): self.fixed = [ {"role": "system", "content": system_prompt}, {"role": "system", "content": global_constraints}, ] self.history_store = ... self.work_state = {} def build_messages(self, current_input): working = [] if self.work_state: working.append({ "role": "system", "content": f"[当前状态] {self.format_state(self.work_state)}" }) if "current_entity" in self.work_state: working.append({ "role": "user", "content": f"[当前问题] {current_input}(请围绕当前对象 {self.work_state['current_entity']} 回答)" }) else: working.append({"role": "user", "content": current_input}) history = self.retrieve_relevant_history(current_input) return self.fixed + history + working核心思路是把"当前状态"显式地放在工作区开头,让模型在生成回答前先看到"现在处理的对象是谁、进行到哪一步了"。这比它自己从历史消息里慢慢推算要可靠得多。
3.2 状态显性化:让模型少推理、多认事实
用户说话天然充满省略和指代。一个人说"我要买那个蓝色的,不对,变成两个吧",如果没有额外的状态信息,模型很难知道"那个蓝色的"到底是什么。如果上下文里恰好有一段更早的商品列表,模型可能猜中,也可能猜错。
我的做法是不要依赖模型从对话历史里反推状态,而是由工程侧把状态显式维护好,然后喂给模型认。比如购物车Agent,每次调用前先维护一份结构化状态:
{ "current_entity": "蓝色保温杯", "stage": "确认数量", "cart_item_count": 2, "total_price": 199.8 }然后把它格式化成"【当前任务状态】"放在工作区。模型看到这个东西,就不需要费劲去历史里找线索了。它只要认这个字段,再结合用户最新的"变成两个吧",就能直接更新数量。
状态显性化还有个额外好处:token消耗大幅下降。一段结构化字段比一大段自然语言历史紧凑得多。但要注意一点,不要天真地让模型在每轮输出完整状态JSON然后再喂回去,这样容易出现自说自话的循环,状态越滚越乱。更稳的姿势是:模型只负责解析用户意图和修改局部字段,状态本身的存储与合并由代码控制。
3.3 工具结果回流:不是原始数据,而是"摘要+标记"
工具调用的结果怎么放回上下文,是Agent工程里最容易被忽视的细节之一。很多系统的做法是直接把API响应原封不动塞进messages,这等于把一仓库的货物搬到谈判桌上,还指望谈判代表能找到正确的报价单。
我现在给工具结果回流定了一套规则。工具端在返回完整数据时,同时生成一个摘要字段;调用侧拿到完整数据后,如果超过一定阈值,就再用轻量处理把它压缩成3到5行关键事实。放回上下文之前,给这个摘要加上明确的标记,并且绑定对应的查询参数。
还是拿订单查询举例。完整API响应可能有几十个字段,但我放回上下文的只有:
{ "query": "order_id: A123", "summary": "订单A123已发货,含手机x1,金额3999,预计明日到达", "queried_at": "2025-01-12 10:23:00" }然后格式化成一行的工具结果摘要放进消息流。这些摘要必须明确标注"这是工具结果,不是用户指令",并且带上查询时间和查询参数。旧的结果在回答完当前轮之后立即退出工作区,归档到日志里,需要时再按条件召回。这套流程执行之后,模型串台的频率肉眼可见地降低。
3.4 信息生命周期:什么时候该进来,什么时候该退场
上下文不是档案室,是工作台。工作台上只留正在用的工具和正在处理的材料,用完了就该归位。这是我后来反复跟团队讲的一句话。
我做了张表来定义不同类型信息的生命周期:
| 信息类型 | 进入时机 | 保留策略 | 退出条件 |
|---|---|---|---|
| 全局约束 | 会话创建时 | 全程保留 | 会话结束 |
| 用户当前意图 | 每轮重新解析 | 本任务期间保留 | 任务完成,转为档案 |
| 工具结果摘要 | 工具调用返回后 | 保留到当前轮回答完成 | 回答完成后删除或归档 |
| 历史对话记忆 | 不再支持当前任务时 | 压缩成摘要存外部 | 不再需要时归档 |
| 用户身份事实 | 会话创建时 | 固定保留,随更新同步 | 用户信息变更 |
这套生命周期模型帮我解决了一个大问题:什么时候清空上下文、什么时候归档、什么时候恢复。以前我总觉得上下文越多越好,生怕丢信息;现在我知道,保留信息的关键不是"多少"而是"秩序"。该退场的信息不退场,就是污染;该召回的没召回,就是缺位。
4. 窗口不够用?压缩与召回的正确姿势
4.1 直接截断为什么是最差的兜底方案
上下文窗口再大也有上限。窗口不够用了怎么办?很多人第一反应是"那我把旧消息丢掉,保留最近的N条"。这个方案最省事,但也最伤人,因为它完全不区分信息的价值密度,只按时间远近决定去留。
问题在于,被丢掉的最早消息往往不是无关紧要的寒暄,而是任务的前提。我在一个多轮整理报告的任务里测试过:用户第一轮给出完整的目标和步骤,后面几十轮都是围绕这个目标的具体操作。当上下文一超过限额,系统自动丢掉了第一轮,模型后续立刻像失忆一样,频繁反问"你需要我做什么",甚至开始自拟一个目标继续执行。这比让它知道完整目标再回答糟糕得多。
所以我的原则是:至少保证"任务启动块"永不截断。这个块包括system指令、首轮用户目标、当前用户问题。其他内容可以被压缩、被归档,但这三样东西必须时刻存在。如果这三个都不够放,那要考虑的不是怎么截断,而是这个任务是不是根本不需要那么长的历史。
4.2 摘要压缩要防止"事实漂移"
有些团队选择用模型把长对话历史压缩成一段摘要,这比简单截断聪明,但同样有坑,我管它叫"事实漂移"。摘要天生就会损失精确信息:数字被四舍五入,订单编号被省略,用户原话里的否定关系被翻转。
我见过最典型的一次事故,原文是"用户说:我不喜欢红色包装,其他都可以",第一轮摘要被写成"用户对包装有偏好",第二轮再压缩的时候模型直接理解成"用户偏好红色包装",整个方向都反了。对话越长、压缩次数越多,这种变形就越严重。
我的对策是用结构化模板压缩,而不是让模型写自由段落。强制摘要生成几个固定字段:实体列表、决策点、未完成任务、关键数字、排除条件。这些字段逐项对应原始信息,逐项校验。摘要只当作索引,关键不可变事实(ID、金额、日期、否定条件)必须保留原始片段。压缩做完之后还要回读一遍,把摘要和原文的关键点对一遍,发现不一致宁可留着原文也别冒险用摘要。
4.3 按需召回:不把整本历史塞回去,只捞当前需要的
上下文窗口有限,但对话总长度是无限的。解决这个矛盾的正道不是无限压缩,而是按需召回:把历史交给外部存储,每一轮根据当前问题只检索出相关片段,再注入工作区。
举例来说,一个客服Agent的用户昨天聊了二十轮关于订单A的问题,今天回来问"昨天那个订单现在到哪了"。模型并不需要昨天完整的二十轮,它只需要知道"昨天用户提到的订单是哪一单、当时的处理进度是什么、用户要求过什么东西"。通过实体抽取拿到"昨天+订单"的查询条件,从存储里把这两条关键信息捞出来就够回答了。
但按需召回有代价:检索可能漏掉关键信息。如果召回结果里没有用户昨天明确说过"必须今天发货"这个条件,模型就不知道今天要优先处理。所以必须做兜底:召回结果带上来源标记,system里明确写"没有查到相关材料就直接说明,不要编造",关键场景用关键词和语义双路检索提高命中率。召回的本质是把无限记忆转成有限上下文的一座桥,但它要和上下文分区配合着用,否则召回来的信息照样可能变成污染。
4.4 分层记忆配置:长会话Agent的最终上下文形态
把前面这些思路串起来,我最后在一套长会话Agent里落地了分层记忆配置:
| 层级 | 内容 | 存储位置 | 使用方式 |
|---|---|---|---|
| L1 常驻内存 | 全局约束、用户身份、当前状态摘要 | 固定字段 | 每轮必带,token约2k |
| L2 工作空间 | 当前任务事实、最近必要轮次、工具结果摘要 | 动态构建 | 每轮重算,token约5-8k |
| L3 短期历史 | 按天/按任务压缩的摘要 | 外部存储 | 按需召回,注入工作区 |
| L4 长期档案 | 原始会话全量日志 | 数据库/文件 | 不上送模型,仅供审计 |
这套配置下,模型每次看到的上下文是"固定区+当前状态+必要的最近几轮+针对性召回的摘要"。token压力可控,上下文整齐干净,出了问题也好定位。窗口不够用的时候,我优先调整L2,让它再精简,而不是去动L1和L3的边界。
5. 从调提示词到设计上下文:一套可以照抄的工程清单
5.1 先把"模型看到了什么"变成可观测的日志
如果你问我做Agent最应该先做哪件事,我第一反应是把上下文日志系统搭起来。没有上下文日志,所有排查都像是蒙着眼睛修电路。我现在团队里定了一条规矩:没有上下文日志的Agent改动不算是改动。
具体要记录的东西包括:每次请求的完整messages快照、每个分区的来源与token数、工具结果摘要与原始数据的对应关系、每次压缩动作的触发条件和结果。日志不需要花哨,关键是要能复现——用户出了一个问题,我能按时间倒推,看到那一次模型请求时上下文的完整面貌。很多"神秘"的模型失误,在日志面前都会变成"哦,原来是这里放了旧数据""原来是这里截断了"。
我自己的习惯是给每条上下文日志加上一个简单的来源标记,比如[system]、[global_state]、[tool_summary]、[history_retrieved]。这样复盘的时候一眼就能看出来,某个模型回答是基于哪部分上下文产生的。看着看着,你会发现大多数问题根本不需要改提示词。
5.2 构建上下文回归剧本,防止改一处坏一片
上下文策略一旦调整,经常出现"这个case修好了,那个case又坏了"的情况。为了不让每次修改变成拆东墙补西墙,我建议准备一套固定的回归剧本。
剧本不需要很大,但一定要覆盖容易出问题的边界场景。我常用的几个固定场景是:
| 场景 | 预期表现 |
|---|---|
| 长对话指代 | 用户说"它",模型能正确指到当前状态对象 |
| 多实体切换 | 从订单A切到订单B再切回来,模型不串台 |
| 信息缺位 | 用户信息不足时,模型明确询问或说明,不编造 |
| 工具结果冲突 | 两次查询结果不一致,模型能识别并追问 |
| 超长窗口 | 接近token上限,任务前提不被丢弃 |
每次改动上下文构造逻辑,我就把这套剧本跑一遍,记录任务完成率、关键信息错误次数、平均token消耗三个指标。改完之后对比表格,指标变好才敢上线。这套方法帮我挡住了好几次"看起来变聪明了、实际整体变傻"的改动。
5.3 review的对象从提示词文本变成上下文构建代码
团队协作方式也因为这个认知改变了不少。以前大家review的是提示词原文,看句式、看结构、看有没有歧义。现在Review的重点变成了上下文构建代码——因为上下文是代码拼出来的,不是文案写出来的,文本层面的肉眼检查已经完全不够了。
同事A就踩过一个很典型的坑。他把"当前查询对象"的状态塞在一条user消息里,没有加任何结构标签,结果模型把这段状态当成了用户的新指令,顺着它输出了一通莫名其妙的话。后来改成一个带"【当前任务状态】"前缀的system消息,放在工作区开头,问题立刻消失。这种事如果只review提示词文本,根本看不出来,必须review上下文是怎么构造的。
我要求上下文构建代码里必须写清楚每个字段的来源、更新时机、退出条件,像画数据流图一样把信息的流动设计明白。哪个字段是用户意图解析出来的、哪个字段是工具结果写入的、哪个字段在什么条件下被清除,都要能追到代码行。
5.4 我反复用到的几条实操原则
最后说几条我自己反复用、每次都能救我一命的原则。
能用字段表达的,决不靠自然语言描述。状态、实体、约束,能结构化就结构化,别指望着模型从一大段历史里自己抽取。
上下文分区不是可选项,是必选项。固定区、工作区、历史区分清楚之后,垃圾和真相就不会挤在一起。
让每个信息块带上来源和时间标记。这条对排查太重要了,没有标记的信息在上下文里就是一个"无主之证",出了问题你都不知道该找谁。
宁可少放,不可乱放。放进上下文的每一条信息都必须有存在的理由,说不出来理由的就不放。少一点上下文,模型反而更清醒。
把压缩和截断看成数据管线的算子,而不是后处理。这句话的意思是,从一开始设计上下文的时候就要考虑信息怎么进、怎么出、怎么归档,等窗口爆了再临时处理,永远是亡羊补牢。
回头想想,做Agent这段时间最大的收获,不是把提示词写得更漂亮,而是把"上下文"从一个模糊概念变成了一等公民。如果你现在也被Agent的"失忆""串台"问题折磨,我的建议很直接:先别急着改提示词,去把每次请求的完整上下文打印出来,一行一行问自己,这里的每条信息对模型回答当前问题到底有没有用。可能你会发现,问题真的不在提示词,而在上下文没做对。