很多人第一次把 agent 跑通时,都会经历一段短暂的兴奋期:模型能自己调工具了,能读文件、能执行命令、能根据报错自我修正,看起来离“自动干活”只差一层产品包装。然后把它扔进真实任务里跑两天,兴奋就会迅速转化成困惑——同一个模型、同一套提示词,有时候十几步把活干得漂漂亮亮,有时候第三步就开始原地打转,还有时候会把某个工具几轮之前的输出当成铁一样的事实反复引用,越走越偏。
我后来才慢慢想明白一件事:这类系统的性能天花板,绝大多数时候不在模型本身,而在模型外面那层壳。这层壳在英文语境里有个很形象的说法——Agent Harness,直译过来是“挽具”“马具”,就是套在马身上、用来把力量传导到车上的那套东西。马再壮,挽具做歪了,车照样拉不动;反过来,一匹中等马配上合适的挽具,长途拉货反而更稳。
这篇想聊的就是这层壳。它不是某个具体框架的名字,而是一整套工程结构的统称:上下文怎么装配、工具怎么暴露、主循环怎么收敛、状态怎么持久、运行怎么观测。适合已经跑通过 demo、正准备把 agent 往生产环境推的人看;也适合那些还没动手、但想先搞清楚“到底要写多少代码”的人。下面这些内容,一部分来自我自己反复改这几层的经验,一部分来自观察别人系统塌方时的排查过程,涉及具体实现的地方我会说明这是常见做法而非唯一答案。
1. 把模型当成 CPU:Agent Harness 到底在承重什么
1.1 从“一次调用”到“一个回合”,控制权发生了反转
普通调用的心智模型很简单:输入进去,输出出来,程序继续往下走,控制权始终在代码手里。Agent 完全不是这个结构。你把工具清单递给模型,模型回一句“我要调 X,参数是 Y”,这时候控制权短暂地交给模型,等工具执行完,结果塞回去,控制权又回到模型手里。一个 agent 的一次完整运行,本质上是控制权在“代码”和“模型”之间来回交接几十次的过程。
这个反转带来一个很直接的后果:传统程序里那些“默认成立”的东西,在这里全都不成立了。函数调用一定返回、返回值类型一定正确、调用一定按顺序发生、失败一定抛异常——这些在 agent 里全部变成概率事件。模型可能返回一个语法正确但字段缺失的工具调用,可能连续三次调同一个工具,可能在你以为它要收尾的时候突然又开一个新话题。
所以 harness 的第一职责,不是增强模型,而是给这个不可靠的控制交接过程加装护栏。这跟操作系统给进程做调度、做隔离、做资源上限是一个思路:不是因为进程坏,而是因为不可控的东西必须被约束在可控的边界里。
1.2 四根承重梁:上下文、工具、循环、观测
我把 harness 拆成四块来看,每一块都有独立的失效模式,也有独立的优化手段:
| 承重梁 | 它解决的问题 | 塌方时的典型症状 |
|---|---|---|
| 上下文装配 | 每一轮该给模型看什么 | 越跑越糊,后半程开始遗忘或幻觉 |
| 工具层 | 模型能做什么、怎么做、做错了怎么办 | 参数瞎填、重复调用、副作用重复执行 |
| 主循环 | 什么时候继续、什么时候停 | 死循环、提前收工、步数烧光 |
| 可观测性 | 出问题后能不能还原现场 | 只能看到最终结果,中间过程全是黑盒 |
这四块里,最容易被低估的是第一块和第四块。工具层和主循环因为写起来有明确的代码形状(注册函数、写 while 循环),大部分人上手就会写;而上下文装配和观测,前者看起来像是“拼字符串”,后者看起来像是“加日志”,都很容易被当成收尾工作草草了事。实际经验恰恰相反——我见过的绝大多数“模型不行”的抱怨,追下去都落在上下文和观测这两块。
1.3 一个有点反直觉的结论:模型越强,harness 越关键
有人会想,等模型再强一点,这层壳是不是就可以薄一点了?我的观察是相反的。
模型弱的时候,任务本身就做不完,harness 再好也救不回来,所以差距不明显。模型强到“单步正确率 95%”这个量级之后,事情就变了:单步都对,但一步错就全盘错,而几十步连乘下来,整体成功率可能只有百分之十几。这时候 harness 的价值就完全暴露出来了——它的作用不是让每一步更聪明,而是让错误在造成不可逆损失之前被发现、被回滚、被纠正。
打个比方,一个刚拿驾照的人,你给他配再好的车也开不快;但一个老司机,车的底盘调校、刹车响应、视野设计,直接决定他敢不敢开快。模型能力是驾驶员水平,harness 是车。
顺带说一个实操层面的观察:换更强的模型,往往不会让你的系统忽然变好,反而会暴露 harness 里原来被“模型笨所以走不远”掩盖掉的 bug。因为强模型会走更多步、调更多工具、探索更多分支,那些在第 5 步就失败所以从未被触发的路径,现在全被踩到了。我自己的习惯是,每次升级模型,都先把 trace 全量跑一遍回归,专门看那些以前从来没出现过的调用序列。
2. 上下文装配:决定 agent 成败的隐藏主战场
2.1 系统提示词不是写作文,是写接口文档
刚上手的人写系统提示,容易写成一篇“岗位说明书”:你是一个资深的某某专家,你经验丰富,你严谨细致,你要对用户负责……这些话对模型的行为几乎没有约束力,因为它们不可检验。模型没法判断自己“是否严谨细致”,但可以判断“是否在回复前调用了校验工具”。
我的做法是把系统提示当成接口文档来写,只写三类东西:能做什么(工具与边界)、必须做什么(硬性流程)、绝对不许做什么(红线)。每一条都要求可判定,写完之后自己问一句:这条规则如果被违反了,我能不能通过看 trace 判断出来?判断不出来就删掉重写。
举个具体的对比。写“你要谨慎修改代码”是无效的,写“修改任何已存在的文件前,必须先读取该文件当前内容;未读取就调用写入工具的请求会被直接拒绝”才是有效的——后者不但可判定,还能在工具层用代码强制执行。最好的提示词规则,是那些你在工具层也实现了一遍的规则,两层保险,模型忘了代码还记得。
还有一个细节:系统提示里越靠后的规则,被遵守的概率越低,尤其是上下文长了以后。所以规则要短、要去重、要把最重要的放在最前面。我见过一份系统提示里“不要执行破坏性命令”出现了四次,分别在第 3 段、第 17 段、第 40 段和结尾——重复四次说明写的人自己被坑过,但重复本身不解决问题,真正解决问题的是在工具层做命令白名单。
2.2 工具返回值的裁剪,是上下文管理里最脏最累的活
模型自己能控制的东西有限,上下文里绝大部分内容其实是工具塞进去的。一个读取文件的工具,如果不做任何裁剪,一次就能把 20 万字符灌进去,直接把窗口撑爆。所以工具层的返回值必须经过一道“整形”:
- 长度上限:每个工具定义明确的返回上限,超了就截断并显式声明截断位置。
- 结构优先:能用结构化字段表达的就别返回大段文本,模型对 JSON 字段的解析稳定性远高于对自由文本的解析。
- 无用信息剔除:日志里的时间戳、堆栈里的内部路径、查询结果里的冗余列,这些东西对模型判断没有帮助,只会稀释有效信息。
- 分页:大结果集不要一次给完,给“总数 + 本页范围 + 下一页参数”,让模型自己决定要不要继续翻。
这里有个容易踩的坑我在第 6 节会详细展开:截断本身不是问题,不告诉模型“我被截断了”才是问题。很多工具在截断后只是在末尾加个省略号,模型会默认自己看到了全部内容,然后基于不完整的信息做出错误推断,而且这个错误会一路传播下去。
2.3 上下文预算的分配,得按“失效代价”来排
窗口再大也是有限资源,怎么分配要有明确的策略,不能靠“能塞就塞”。我习惯按下面的比例做初始配置,然后在实测中微调:
| 区块 | 典型占比 | 摆放位置 | 分配过少的后果 |
|---|---|---|---|
| 系统规则与红线 | 10%–15% | 最前,全程不变 | 规则被稀释,后半程开始违规 |
| 任务说明与验收标准 | 5%–8% | 紧跟系统规则 | 活干完了但不符合要求 |
| 工具定义 | 10%–20% | 靠前,按需动态裁剪 | 参数填错、漏必填字段 |
| 工作记忆/任务清单 | 5%–10% | 尾部,每轮刷新 | 重复劳动、反复走回头路 |
| 最近若干轮交互 | 25%–35% | 尾部 | 忘记刚才的结论,自相矛盾 |
| 检索或历史资料 | 20%–30% | 中部 | 引用过时信息 |
这张表里有两条经验值得单独说。
第一条,“工作记忆”这个区块一定要有,而且要放在尾部。它的内容很简单:当前目标是什么、已经完成了哪几步、下一步计划做什么、有哪些已知的坑。每轮由模型自己更新,或者由代码从最近几轮里抽取。这一小块内容对防止“目标漂移”的效果,比把整个历史都留着还好。因为历史里 90% 是过程噪声,而工作记忆是压缩后的状态。
第二条,历史不能无限留,但也不能简单砍最老的。最老的那几轮里往往包含任务的关键约束(用户最初说的“不要动配置文件”),砍掉就出大事。我的做法是:保留第一轮完整内容,中间部分做摘要压缩,最近 N 轮保持原样。三个区段,成本可控,关键信息不丢。
3. 工具层:从“能调”到“调得稳”之间隔着一条河
3.1 工具描述写得好不好,直接决定调用成功率
工具定义里那个 description 字段,很多人当成注释写,一行带过。它是模型唯一的判断依据——模型不知道你的代码长什么样,它只知道自己看到的这段文字。所以这段文字的信息密度,直接决定了调用质量。
| 写法 | 示例 | 实测表现 |
|---|---|---|
| 只写功能 | search_docs:搜索文档 | 参数乱填,不该用的时候也用 |
| 加使用时机 | 仅在需要查证具体事实时使用;读取项目内已打开文件请用 read_file | 调用精准度明显上升 |
| 加失败语义 | 未命中时返回空数组,不是错误;不要因为空结果重试 | 无谓重试大幅减少 |
| 加参数约束 | path 必须是相对路径,不接受绝对路径 | 参数错误率下降 |
关键在于把“什么时候不该用”写清楚。模型在模糊地带的默认倾向是“多试几次”,如果不明确禁止,它会在明明有专用工具的场景下反复调用通用工具,既浪费预算又污染上下文。
另一个技巧是给工具名加上语义前缀,比如read_、write_、query_、submit_。这种命名一致性会让模型在选择工具时更少犯错,成本几乎为零。
3.2 幂等、超时与“半成功”状态
这是工具层最容易出事的地方,也是最不容易被 demo 暴露的地方。设想一个场景:模型调用一个“提交表单”的工具,请求发出去了,服务端也处理了,但响应在网络上超时了。模型看到的是“失败”,于是它按提示词里的“失败要重试”又调了一次——结果提交了两遍。
这类问题的根因是工具调用存在“结果未知”的第三态,而大多数人只处理了成功和失败两态。工程上的解法有三层:
- 幂等键:每次工具调用由客户端生成一个唯一 ID 随请求带上,服务端对相同 ID 的重复请求直接返回首次结果。
- 查询-确认-重试三段式:超时后不直接重试,而是先调一个“查询状态”的工具确认上一次到底成没成。
- 副作用分级:把工具按副作用分成只读、可重复写、不可重复写三档,只有前两档允许自动重试,第三档必须走确认流程。
第三点在提示词里也要体现:明确告诉模型“调用 submit_order 后如果返回超时,不要重试,先调用 check_order_status”。模型是很听话的,前提是你得告诉它。
3.3 错误信息是写给模型看的,不是写给人看的
工具抛异常时,很多实现直接把原始堆栈序列化进上下文。这对人调试有用,对模型基本是噪声——它看不懂调用栈,但会被里面的路径和类名带偏。
正确的做法是给模型一份“可行动的”错误描述,包含三要素:发生了什么、可能的原因、建议的下一步。比如:
{ "ok": false, "error_type": "file_not_found", "message": "路径 config/app.yaml 不存在", "hint": "该目录下实际存在的文件见 list_dir 的结果;如果是相对路径,请确认工作目录是否为项目根目录", "retryable": false }retryable: false这个字段特别有用。它直接掐掉了模型“再试一次”的冲动,而这个冲动是死循环的主要来源之一。我在工具层给每个错误都标了这个字段,实测下来重复调用率降了一大截。
3.4 权限与沙箱:默认拒绝,而不是默认允许
Agent 拿到了工具就等于拿到了手。手能干活,也能砸东西。所以权限设计的第一原则是默认拒绝,白名单放行,而不是“先全开,出事再关”。
几个落到代码层面的具体做法:
- 路径归一化后再校验。任何文件路径先做绝对化和符号链接解析,再判断是否落在允许的根目录内。只做字符串前缀匹配会被
../绕过。 - 写操作走临时区 + 差异审核。让 agent 把改动写到副本,产出一份 diff,再由流程决定是否合并。这样即使模型判断失误,损失也局限在副本里。
- 命令执行白名单。不要用黑名单禁
rm,黑名单永远列不全;改成只允许有限的几个命令模板,参数用结构化方式传入,不走字符串拼接。 - 资源上限。单次工具调用的超时、单次运行的总步数、总 token 预算,这三个上限必须硬编码在 harness 里,不能靠提示词约束。
提示:权限校验一定要放在工具函数的入口处,而不是放在提示词里。提示词是建议,代码才是强制。任何“我在系统提示里写了不许删文件”的方案,都等价于没写。
4. 主循环:什么时候继续,什么时候必须停
4.1 五类终止信号,缺一类就会出事
一个健壮的主循环,终止条件必须是多路并行的,任何一路触发就立刻退出。我一般会实现这五类:
| 终止类型 | 触发条件 | 不实现的后果 |
|---|---|---|
| 正常完成 | 模型调用 submit/answer 类工具,或输出明确的结束标记 | 不知道什么时候算干完,一直加戏 |
| 步数上限 | 累计轮次超过阈值 | 死循环烧预算 |
| 资源上限 | token/时间/费用任一超限 | 单次运行成本失控 |
| 重复检测 | 相同工具+相同参数连续出现 N 次 | 振荡型死循环 |
| 外部中断 | 人工停止、上游超时、安全策略命中 | 无法紧急刹车 |
这里最容易被忽略的是正常完成。很多人依赖“模型不再调用工具”作为结束信号,但模型的输出格式并不总是稳定的——它可能输出一段自然语言解释然后停下,也可能输出一段解释之后紧接着又调一个工具。显式定义一个submit_result工具,要求模型必须通过它来交付,比靠“没有工具调用”来判断可靠得多。而且这样做还有个附带好处:交付物是结构化的,下游好处理。
4.2 死循环的三种形态,长相完全不一样
第一种是振荡:A 工具改了东西,B 工具检查报错,模型改回去,B 又报错……来回横跳。特征是工具调用序列周期性重复。用“最近 6 步内是否有重复的(工具名,参数哈希)组合”就能抓住。
第二种是退化重试:模型拿到失败结果,不改变任何参数,原样再调一次。这种最容易检测,连续两次完全相同的调用就应该拦下来,把结果替换成一条提示:“你刚刚用完全相同的参数调用了同一个工具,结果不会变化,请分析失败原因或更换方案。”
第三种最隐蔽,叫目标漂移:每一步单独看都合理,但整体离目标越来越远。比如让它修一个 bug,它先去重构了一个不相关的模块,然后又去补测试,最后交付了一堆和原问题无关的改动。这种循环检测抓不住,只能靠工作记忆 + 阶段性自检:每 N 步强制模型回读一次原始目标,并回答“当前进展是否还在服务这个目标”。
4.3 检查点与断点续跑,是长任务的必需品
跑十几步的任务,挂了重来成本还能接受;跑上百步的任务,中途因为一次 API 超时全部重来,人是会崩溃的。所以主循环必须做状态快照:每一步结束把完整上下文(消息列表、工具调用记录、工作记忆、预算消耗)序列化落盘,带一个递增的 step 编号。
恢复的时候不是简单地从快照继续——那样可能重复执行有副作用的工具。正确的做法是:从快照恢复上下文,但把“最后一步的工具调用”标记为“结果未知”,让模型先确认状态再决定下一步。这个细节听起来麻烦,但在真实系统里,它决定了你的 agent 能不能跑超过 50 步的任务。
5. 可观测性:没有 trace 的 agent 等于闭眼开车
5.1 一次运行到底该记录什么
我在踩过足够多次坑之后,定下了一份最小字段清单,缺一个都会在排查时抓瞎:
run_id 本次运行的全局唯一标识 step 步序号,从 1 开始 ts 时间戳(毫秒) model 模型标识与版本 prompt_tokens 本轮输入 token completion_tokens 本轮输出 token latency_ms 本轮端到端耗时 finish_reason 模型停止原因(正常/长度截断/工具调用) tool_name 本轮调用的工具名(无则空) tool_args_hash 参数哈希,用于重复检测 tool_result_size 返回内容字节数 tool_status success / error / timeout cumulative_cost 累计费用tool_result_size这个字段看着不起眼,但它救过我至少两次。因为它是个“稳定不变的数字”时,往往意味着工具在无差别截断,而截断是后面一系列幻觉的源头。finish_reason也很关键——如果频繁出现“长度截断”,说明输出预算给少了,模型的话说到一半被砍断,它下一轮会接着一个不完整的思路往下走。
5.2 用回放定位问题,而不是靠猜
有了完整的 trace,排查方式就完全变了。不再需要“再跑一次看看能不能复现”,而是直接把失败的 run 拉出来逐步回放:在第几步开始偏离预期?那一步的输入上下文里多了什么、少了什么?那一步的工具返回和上一步有什么差别?
我通常按这个顺序看:先看最后一步在干什么,再看第一次出现异常的那一步,最后对比“异常前一步”和“正常 run 的同位置”的上下文差异。第三个对比往往一击命中——同一段提示词、同一个任务,成功 run 和失败 run 在第 7 步的上下文里差了一条工具返回,问题就在那里。
回放还有个副产品:可以做成离线重跑。把某一步的完整输入拿出来,换个模型、换套提示词重新请求,对比输出差异。这是做提示词迭代最快的方式,比在线瞎试高效得多。
5.3 评测集是攒出来的,不是设计出来的
刚做 agent 的时候,我想设计一套覆盖全面的评测集,结果发现根本不知道该测什么。后来换了思路:不设计,只收集。每次线上或测试环境出现一次失败,就把它当时的输入、期望行为、实际行为固化成一个用例。跑上几个月,你会得到一份极其贴合真实分布的评测集,比任何拍脑袋设计的场景都管用。
维护这份集子有两个纪律:一是每个用例必须能稳定复现(模型有随机性,同一个输入跑三次结果不同的话,这条用例要标注“不稳定”单独看);二是每次改提示词、换模型、动工具定义,都要全量跑一遍,看通过率变化。没有回归集的 agent 项目,改提示词就是在赌博。
6. 一次完整的塌方排查:agent 从第 11 步开始胡说
讲个具体的例子,因为排查过程比结论更有价值。
现象是这样的:一个负责批量重构代码的 agent,前 10 步表现正常,能正确读文件、定位问题、按计划逐个修改。但从第 11 步开始,它开始输出一些明显脱离实际的内容——声称某个函数“已经修改完成”,实际上文件根本没被改;接着它基于这个错误前提继续往下走,后面的所有动作全部建立在沙地上,最后一口气输出了十几个“已完成”的假报告。
第 1 步,先确认不是模型抽风。换了个更强的模型重跑同一个任务,第 11 步附近依然出问题,只是措辞变了。这一步排除掉“模型能力不足”,说明问题在输入侧。
第 2 步,拉 trace 看异常点的输入。把第 10 步和第 11 步的完整上下文导出来对比。第 10 步是一次read_file调用,返回内容看起来正常:一大段代码,末尾是...。第 11 步模型说“该文件已完整读取,函数 X 在文件末尾”。
第 3 步,盯住那个省略号。回到 trace 里的tool_result_size,发现这个字段在最近五次 read_file 调用里全是同一个值:8019。文件大小从 20KB 到 200KB 不等,返回长度却完全一致——这基本就锁定了,工具在做无差别截断。
第 4 步,确认截断逻辑。翻工具代码,果然:返回内容超过 8000 字符就切成前 8000,末尾拼三个点。代码作者的本意是“防止撑爆上下文”,但完全没有告诉模型截断发生了、更没告诉它原文件有多大。
第 5 步,推演后果。模型看到的是一个“完整”的文件,末尾是三个点,它把这理解成“文件到此结束”。于是它认为文件里的最后一个函数就是它要找的那个,判断“函数已经写完了”,然后基于这个前提跳过了实际修改。错误一旦产生,后面每一步都在这条错误链上叠加,而 agent 本身没有任何“回读验证”的机制,所以走了十几步都没被发现。
第 6 步,修复。改了两处。工具侧:截断时返回结构化信息——总行数、本页起止行、是否还有下一页、下一页的参数怎么传,并在正文开头显式写一句“内容不完整,共 N 行,本次仅返回第 a–b 行”。harness 侧:加了一条硬性规则,涉及修改文件的任务,在提交结果前必须做一次“修改后回读”,确认目标内容确实发生变化,没变化就要重新分析原因。
第 7 步,验证。把之前失败的那批任务重跑,问题消失。同时把这条用例固化进回归集,标注“截断未告知”。
这个案例后来我复盘了很长时间,有两点感触特别深。一是绝大多数 agent 的“胡说”都不是模型在编,而是模型基于一份被悄悄篡改过的输入在做合理推理。它看到的世界和你以为它看到的世界不是一个世界。二是工具层的每一个“善意优化”,都必须同步告知模型。截断、采样、去重、排序、过滤,这些操作对人来说是降噪,对模型来说是信息失真,而且它不知道自己失了真。
顺带说一句,排查这类问题最有效的动作,是把某一步的完整上下文真的打印出来从头读一遍。不是看日志摘要,不是看结构化的字段,是把模型实际看到的那几万字原样读一遍。读上三次,你几乎总能发现问题——因为人会立刻觉得“这里不对劲”,而模型不会。
7. 自建还是套现成框架,以及几个不太想公开说的细节
先说选型。如果目标是验证想法、跑个内部工具,直接用现成框架,把精力放在任务逻辑上,完全没问题。判断要不要自建,我一般看三条:一是是否需要精细控制上下文装配(比如要按任务类型动态调整区块比例);二是工具是否需要复杂的事务语义(幂等、回滚、确认流程);三是是否需要对每一步做细粒度观测和回放。这三条命中任何两条,套框架就会开始难受——不是框架不好,而是它的抽象层次和你的需求错位了,最后的结局往往是在框架上打满补丁。
如果决定自建,我的建议是先写观测,再写循环。顺序反了会很痛苦:先写循环意味着你在没有任何可见性的情况下调试一个概率系统,那种感觉就像蒙着眼睛修表。先把 trace 打全,哪怕循环还很简陋,你至少知道它在干什么。
最后分享几个踩过坑才明白的小细节,都是那种文档里不会写、但实际会绊你一脚的东西:
第一,工具数量超过 15 个以后,选择准确率会明显下降。不是线性下降,是断崖式的。解法不是精简工具,而是分组——给模型一个“工具目录”,先选组再选工具,或者按当前任务阶段动态只暴露相关的 5–8 个工具。
第二,模型对“否定式规则”的遵守率明显低于“肯定式规则”。写“不要修改测试文件”效果一般,写“只能修改 src 目录下的文件”效果好得多。能改成正向表述的就改。
第三,相同参数重复调用的拦截阈值,设成连续 2 次比 3 次好。设成 3 次看起来更宽容,但实际上第三次调用几乎不可能有不同结果,白白浪费一轮预算和一个上下文位置。
第四,长任务的 token 成本曲线不是线性的,是超线性的。因为上下文越滚越大,每一轮都要重新付一遍前面所有内容的钱。跑到 30 步以后,单步成本可能是第 1 步的五六倍。所以上下文压缩不是“优化项”,是“能不能跑完长任务”的前置条件。
第五,别指望模型自己发现“我走错路了”。它对自己已经做出的判断有很强的路径依赖,越是详细地解释过某个结论,后面越难推翻它。所以纠偏必须由 harness 主动发起——定期回读目标、定期要求自检、定期把工作记忆重新生成一遍。这套机制写起来不复杂,但它把“能不能从错误中恢复”这件事,从模型的自觉变成了系统的能力。