去年底我做过一个生成周报的 Agent,demo 阶段顺得让我有点飘。输入项目名,它自己拉工单、翻提交记录、套模板,输出一份格式漂亮的周报。
上线第一周,它把一条没完成的里程碑写成了已完成,发给了负责人。
我去翻日志,发现它其实“看”到过正确状态。只是在第十二轮上下文压缩的时候,这条状态被摘要掉了,而模型继续用更早的一版信息往下写。整个过程没有报错,没有任何异常信号,输出读起来完全合理。
那一刻我意识到,问题不在提示词。提示词写得再好,也没法让一个已经不在上下文里的事实重新出现。
Demo 和上线之间隔着的不是模型
有个说法我认同:Agent 等于模型加线束。模型负责决定下一步做什么,线束负责让这个决定在真实环境里连续、可恢复、可审计地执行下去。
这两个东西优化的目标不一样。提示词优化的是单轮质量,线束管理的是多步可靠性。前者决定回答得好不好,后者决定任务能不能做完。
demo 环境和真实环境的差别,也不是“数据脏一点”。真实环境里会有超时、权限拒绝、返回格式变化、任务跑到一半被打断、外部状态在两次调用之间被改掉。这些都不是语言问题,是运行时问题。指望模型在自然语言里“记住要重试”,等于把执行控制交给了一个概率系统。
失败的形态也很固定,基本能归成几类:编造不存在的工具或参数;反复搜索、反复归纳,看起来在努力,其实没推进;上下文溢出之后忘了原始目标;中途失败只能从头再来;以及最麻烦的一种,输出看起来完全正确,但没有任何机制去验证。
最后这一类最难发现,因为监控通常只盯报错。一个“成功地给出了错误结果”的系统,在报表上是健康的。
我见过一次很典型的串联故障。任务是给一个服务加状态页,部署到测试环境。它先改了前端,顺手调了一个不该碰的部署脚本;修的过程中进入循环,反复重启、反复改同一段配置;会话变长之后,压缩把“测试库只读”这条约束丢了;最后它看了一眼 diff,回复已完成,但没有真的访问过页面。四个错误,每一个单独看都不致命,串起来就是一次事故。
所以我判断一个 Agent 是不是接近可用,不看提示词多长,看四件事:上下文有没有分层,工具是不是有契约,权限是不是落在执行层,结果有没有外部验证。缺一件,它就更像一个会调工具的聊天框。
再加一件:出问题的时候,能不能还原它到底经历了什么,是我现在最看重的一件事。工具名、参数摘要、耗时、返回码、重试次数、压缩触发时间,这些如果没记,排障就只能靠猜。我后来把这五个信号当成了最低配置,它们比任何一条新提示词都有用。
这套最低配置听起来像可观测性,算的其实是事故复盘的成本。我后来把每次任务的完整轨迹存下来,包括模型看到的上下文快照、每次工具调用的原始返回、被丢弃的中间结果。存这些东西不便宜,一次长任务的日志能到几十兆,但真正出事的时候,能少猜一半。更实际的好处是,它们能变成回归样本:同一个失败跑通之后,把那次输入留下来,下次改提示词、换模型、调工具,都先拿它跑一遍。没有这批样本,所谓的优化基本靠感觉。
我也不再相信“换个更强的模型就好了”。模型升级确实能消掉一批低级错误,但线束的缺口不会因此消失。权限、超时、幂等、审计,这些是系统属性,不是智力属性。一个足够聪明的模型仍然可能在第两百步忘记原始目标,或者把一个格式正确的空结果当成成功。能力提升改变的是概率,不是结构。这也是我不太愿意把资源全押在“等下一代模型”上的原因:等得到,问题会小一点;等不到,系统还是不能上线。
框架选型也在这层。代码优先的框架灵活,适合行为本身就是产品的场景,但状态持久化、幂等、回滚、审批、审计通常要自己补。工作流编排器反过来,流程可控、恢复能力强,但不擅长开放式推理。我现在的看法是,先看难点在“想”还是在“跑”,别用一个万能框架同时解决两件事。
后来复盘那个周报 Agent,真正花时间的不是把提示词改好,而是补三样东西:一个能重放历史输入的测试集,一套工具调用的参数校验,以及一个在状态不一致时拒绝继续执行的开关。这三样加起来写了两周,改提示词只花了半天。这个比例和我最初的预期完全相反。
上下文要分层
我踩过的最贵的一个坑,是把所有东西都塞进一个长提示词。
短期看信息很全,长期一定会变贵、变慢、变乱。到三十轮以后,旧计划和新状态互相冲突,模型会把已经失效的安排当成当前事实,而且它没有任何理由怀疑自己。
后来我把它拆成三层:
最近几轮对话和最新工具结果,放在短期上下文里,用完就滚掉。
跨会话可能有用但不构成当前事实的东西,放进可检索的记忆里。
真正决定任务走向的状态,写成一个结构化对象。
第三层最容易被忽略,但它才是真相源。向量检索擅长找“相关内容”,不擅长回答“现在到底在哪一步”。如果状态里已经写了等待审批,而检索又捞回第四步的“继续部署”,没有一个明确的状态对象,模型就会接着往下跑。
状态大概长这样,重点是字段而不是格式:
1 | { |
摘要也要面向执行,不是面向阅读。目标、已完成、未完成、最近一次失败的原因、硬约束,这五项必须在。写成一篇通顺的会议纪要没有用,模型下一轮要的是能直接决定动作的信息。
压缩和重置是两种策略,得分开用。压缩便宜,但会丢细节,而且丢的方式不可控;重置干净,但要求交接文档足够完整。我的经验是,任务跨过几十轮之后,重置加结构化交接比反复压缩更稳,代价是要多写一份“交接单”。
状态对象还有一条容易被忽略的规则:写入必须收敛。多个步骤同时更新同一个字段,后写的会覆盖先写的,如果没有人负责裁决,状态就会在几次重试之后变得自相矛盾。我的做法是给每个字段指定唯一的写入方,其他步骤只能读。确实需要改的时候,走一个显式的转换动作,并把原因记下来。这样看起来啰嗦,但它让“当前在哪一步”这个问题只有一个答案。
另一个细节是状态里不要放自然语言的长段落。目标可以是一句话,约束最好是可以被程序检查的条件,比如“不得修改生产配置”不如“目标环境不等于 prod”。后者能直接在执行层拦住,前者只能靠模型自觉。我吃过这个亏:约束写得越像劝告,越容易被绕过。
状态还会过期。一个三天前标记为“等待审批”的任务,今天可能早就被别的流程处理掉了。所以每次恢复执行前,我会先做一次状态校验,把外部系统的真实状态拉回来对一遍,不一致就以外部为准。这一步很枯燥,但它挡住了不少“基于旧状态继续跑”的假性推进。
检索本身也需要约束。相似度高不等于有用,尤其是查询很短的时候,几乎什么都能捞回来一点。我一般会加两个过滤:时间窗和来源。超过一定天数的记忆默认降权,模型自己写的推测不进检索,只有用户确认过或者被外部验证过的事实才有高权重。这样做的代价是召回变低,但错把旧结论当成现状的概率也低了。
写入记忆的标准也要收紧。模型的自我复述、未验证的推测、上一轮的计划,都不该直接进长期记忆。只写用户明确说过的偏好、已经验证的事实、可复用的结论。不然检索会把噪声反复捞回来,越跑越偏。
举个更具体的。一个修 bug 的 agent 要跨二十步工作:读需求、跑测试、定位文件、改代码、再跑测试。如果只靠聊天历史,到第十五步它可能已经忘了最初失败的是哪个用例,或者刚才改过哪几个文件。状态对象的作用就是让这些事实不依赖记忆。上下文重置之后,新实例读一遍状态就能接上,而不是从一大段自然语言里猜。
这套东西的代价很直接:更多代码、更多同步点、每次状态更新都可能写错。我一开始嫌麻烦,用了一个长提示词顶了两个月,结果全花在打补丁上。分层不是优雅,是省下后面的排查时间。
工具是合同,权限不能写在提示词里
给模型一个万能工具,是最省事也最危险的做法。
我见过把 run_sql 直接暴露给模型的系统。它确实灵活,能查任何东西,也能改任何东西。一次参数拼错,查询就变成了更新。
后来我把它拆成三个只读工具:按邮箱查订单、按订单号查详情、查退款状态。参数有类型和范围,返回是结构化字段,超时和行数都有上限。模型能做的事情变少了,但每一次调用都可校验、可统计、可追责。
工具的参数定义不只是给模型看的说明书,它是执行合同。它挡住 "limit": "all" 这种脏输入,也让权限能落到具体字段,而不是停留在“请谨慎操作”这种句子上。
工具拆细的代价也很直接:调用次数变多,延迟变高,编排逻辑更复杂。一个万能接口一次能查完的事,拆成三个之后可能要走三轮。我接受这个代价,因为可校验比快几百毫秒重要,而延迟可以用并行和缓存补,越权写坏的东西补不回来。
权限必须由执行层判断,不能在提示词里商量。默认拒绝比默认允许安全,只读比可写安全,窄工具比全能工具安全。高风险动作要单独拎出来:部署、写库、发外部邮件、推主分支,这些都应该走人工确认,而不是让模型一跳直达。
分级大概是三层。只读工具默认开放,限行数、限超时;受控写工具走参数白名单,必要时人工确认;高风险执行工具进沙箱,强审批,最小权限。听起来很重,但真正出事的往往就是“给它一个 shell 让它自己看着办”。
还有循环。我见过一个 agent 为了修一个测试失败,反复跑测试、改文件、再跑测试,二十分钟打了上百次调用。它不是笨,是没人告诉它什么时候该停。步数、调用次数、执行时间、成本,任何一个超了都应该中断,而不是等它自己意识到没有进展。
沙箱也一样。个人实验用容器加临时目录就够了;一旦涉及多租户、执行模型生成的代码、允许联网、接触真实数据,就该考虑更强的隔离。代价是基础设施复杂度上升,这个代价我认,因为一次越权写操作的修复成本远高于它。
重试也要当成接口的一部分来设计。网络抖动、限流、上游超时,这些在真实环境里是常态,模型看到失败之后很自然地会再调一次。如果写操作没有幂等键,第二次调用就会变成重复下单、重复发邮件、重复扣款。我的做法是让写工具必须带一个由调用方生成的键,服务端按这个键去重,重复请求返回第一次的结果。模型不需要理解幂等,它只需要看到一个稳定的返回。
工具返回也要克制。把整个响应体塞回上下文,看起来信息完整,实际上很快就把窗口撑满,还会把无关字段混进判断。我现在的习惯是让工具层先做一次裁剪:只返回这次决策需要的字段,错误也归一成几种可处理的类型,而不是把上游的堆栈原样抛给模型。堆栈留给人看,模型看结论。
人工确认也要设计。确认框里只写“是否允许执行”没有意义,审批人需要看到这次调用的参数、影响范围,以及失败之后能不能回滚。我见过审批流最后变成无脑点同意,因为每一次弹窗都给不出判断所需的信息。那不是人失职,是界面把责任变成了形式。
审计也不是给合规看的摆设。每次工具调用记下谁发起、参数是什么、返回什么、耗时多少,出问题的时候才能回答“它到底做了什么”。没有这层记录,排障会退化成复读提示词。
外部内容要按不可信处理。网页、搜索结果、仓库里的 README、工单正文,都可能藏着指令。模型读了之后照做,不是提示词没写好,是权限边界没划。所以真正的防线在执行层:出网白名单、命令白名单、写操作审批,而不是在提示词里写“不要听信网页内容”。
验证必须外置,代价自己承担
我最不相信的一句话是“模型自查后确认没问题”。
生成和评审如果在同一个上下文里,它们大概率共享同一套偏差。它会很自信地批准自己的错误答案,而且理由充分。这不是态度问题,是结构问题。
能外置的验证一定要外置,顺序按客观程度排:规则校验、静态检查、程序化测试、真实环境冒烟,最后才是模型评审。规则检查最快最便宜,只能抓显式问题;测试最接近真实交付标准,但需要提前建设资产;模型评审覆盖主观维度,但不能当唯一闸门。
一个最小回路是这样:生成一小步,跑验证,把失败信号结构化地喂回去,再生成,超过次数就停,交给人。
反馈的质量比次数重要。把两千行日志原封不动塞回去,只会浪费 token。有用的反馈是失败用例名、报错栈、期望值和实际值、相关文件行号。
还有一个习惯我后来才养成:长任务每轮开始前先跑一次基础检查,确认环境没有处于“坏了但没记录”的状态。不这么做,前面的错误会一直滚到后面,等发现的时候已经分不清是哪一步弄坏的。
验证回路要有人维护,这是最容易被低估的成本。测试会过期,外部接口会变,样例会慢慢不再代表线上流量。我见过团队把评测集建起来之后就不管了,半年后它还在跑,分数很好看,但和真实故障已经没关系。比较现实的做法是给评测集定一个负责人和一次季度复核,把线上新出的故障补进去,把失效的样例删掉。
还有一件事:验证通过不等于任务完成。程序能证明的东西有限,剩下的部分要么靠人抽查,要么靠业务指标兜底。我现在会在任务结束时输出一份可核对的清单,写清楚哪些结论有证据、哪些只是推断、哪些步骤被跳过。这份清单不是给模型看的,是给接手的人看的。它让我在签字之前知道自己签的是什么。
成本也要提前算。一个任务跑三次验证,token 和延迟都会上去,有些场景根本付不起。所以我会先分级:低风险的查询类任务,规则校验加一次冒烟就够;涉及写操作和外部沟通的,才上完整的回路。统一标准看起来公平,实际会让团队在关键时刻绕过它。
验证粒度也要小。一次改十几个文件再跑全量测试,失败之后根本定位不了。改一个函数、跑一组测试、过了再往下,慢一点,但每一步都可信。
上线也别一步到位。我的顺序是:本地回放测试集,预生产 shadow,只读模式,小流量灰度,最后才开写权限。每一层都观察同样的几个指标,任何一层异常就停。这个顺序很慢,但它把“上线后才发现”变成了“上线前就知道”。
这套东西的代价也很清楚:变慢、变贵、要维护测试。小任务上用它是负收益,我一个人跑一次就完事的事情,不会为它写验证回路。
线束做到最后,评价标准其实很朴素:换一个人接手,能不能在半小时内看懂它为什么停、停在哪、下一步该做什么。能,就说明这层壳是有效的;不能,再多的提示词也只是把问题往后推。
线束越厚,系统越稳,也越不灵活。有些我现在认为必需的护栏,可能只是当前模型能力的补丁。等模型自己能在长流程里维持状态,第一层的摘要和状态对象也许就不需要这么重。
但我怀疑“能跑三天”和“能跑三分钟”之间的差距不会自动消失。只要 Agent 还要接触真实系统,中断、漂移、越权、假性完成就会一直存在,而这些都不是靠一句话能解决的。
我现在的做法是:先承认模型是不可靠的执行者,再决定哪些不可靠可以接受。这个顺序反过来的话,通常会得到一个很漂亮的 demo,和一个不敢上线的系统。
评论