
很多人第一次把 agent 跑通时都会经历一段短暂的兴奋期模型能自己调工具了能读文件、能执行命令、能根据报错自我修正看起来离“自动干活”只差一层产品包装。然后把它扔进真实任务里跑两天兴奋就会迅速转化成困惑——同一个模型、同一套提示词有时候十几步把活干得漂漂亮亮有时候第三步就开始原地打转还有时候会把某个工具几轮之前的输出当成铁一样的事实反复引用越走越偏。我后来才慢慢想明白一件事这类系统的性能天花板绝大多数时候不在模型本身而在模型外面那层壳。这层壳在英文语境里有个很形象的说法——Agent Harness直译过来是“挽具”“马具”就是套在马身上、用来把力量传导到车上的那套东西。马再壮挽具做歪了车照样拉不动反过来一匹中等马配上合适的挽具长途拉货反而更稳。这篇想聊的就是这层壳。它不是某个具体框架的名字而是一整套工程结构的统称上下文怎么装配、工具怎么暴露、主循环怎么收敛、状态怎么持久、运行怎么观测。适合已经跑通过 demo、正准备把 agent 往生产环境推的人看也适合那些还没动手、但想先搞清楚“到底要写多少代码”的人。下面这些内容一部分来自我自己反复改这几层的经验一部分来自观察别人系统塌方时的排查过程涉及具体实现的地方我会说明这是常见做法而非唯一答案。1. 把模型当成 CPUAgent 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 主动发起——定期回读目标、定期要求自检、定期把工作记忆重新生成一遍。这套机制写起来不复杂但它把“能不能从错误中恢复”这件事从模型的自觉变成了系统的能力。