
1. 先把 Harness 这个词拆开它到底管什么harness 在这两年被叫得越来越响尤其是各种模型前面挂上自己的名字之后问 harness 是什么、harness 和 agent 区别在哪、怎么装、插件怎么打包的人一下子多了起来。我自己是从去年秋天开始折腾个人 harness 的起因特别朴素手里有几个模型日常要写代码、跑测试、查日志、翻文档每次都得人肉把上下文复制来复制去一天下来光粘贴就能耗掉一两个小时。后来我干脆把这套流程抽出来做成一个自己能看懂、能改、能随时插东西的 harness才算把这件事理顺。这篇文章就是这段探索的完整记录。先说结论省得你看到一半才发现方向不对。harness 不是模型也不是那个会自己思考的大脑。它更像是套在模型身上的那副挽具——对这个词本来的意思就是马具。马有力气但光有马它拉不动车得有挽具、有车辕、有车夫手里的缰绳马的力量才能被导向一个具体的目标。放到我们这儿模型负责生成下一步动作的意图harness 负责把这个意图安全地执行下去、把结果整理干净塞回去、把边界守住、把过程记下来。谁决定做什么那是 agent 的事谁保证这件事真的能做成、做不坏、可复现那是 harness 的事。这个区分看着像文字游戏但它直接决定了你搭东西的时候会踩哪些坑。我一开始就把它俩混着看结果写出来的东西又当调度器又当规划器状态全糊在一起一个环节报错整条链路就断调试的时候连到底是模型想错了还是我执行错了都分不清。这大概是所有从零手写 harness 的人都会经历的第一课。那么什么情况下你该认真考虑自己搞一个 harness而不是随便写个几十行的脚本调用一下接口就完事我的判断标准有三条。第一你的任务不是一次性的问答而是要连续跑很多步、中间结果要留着、断了还想接着跑。第二你需要模型去碰真实环境——读写文件、执行命令、调外部接口这时候安全边界就变成了必须品而不是加分项。第三你希望这件事可复现、可观测出了问题能回放而不是靠我再试一次。只要踩中两条脚本这条路就会很快走到头。反过来如果你只是偶尔问几个问题、做个翻译、写段文案那真没必要上 harness一个封装好的请求函数足够硬套框架只会给自己找麻烦。这个判断我后面还会再展开讲因为它牵扯到整个设计思路的取舍。1.1 别把 Harness 当成 Agent 的另一个名字我见过太多讨论把这两个词当同义词用然后就一路跑偏。说个具体的对照一个 agent 的核心是规划——它要拆任务、定优先级、决定什么时候放弃一条路换一条而 harness 的核心是承载——它要提供工具、维护上下文、执行调用、记录轨迹、限制越界。用一个更日常的比喻。你请了个装修师傅来家里干活师傅是 agent他有经验、会判断、能决定先刷墙还是先铺地。但师傅不能凭空变出工具和材料他需要你把钥匙给他、告诉他哪面墙能动哪面不能动、水电气在哪儿、材料堆在哪、干完活把票据留好。这套钥匙、规矩、材料、票据就是 harness。师傅再厉害没有这套东西他连门都进不去。所以你会发现harness 里真正难的部分几乎都不是聪明的活而是笨的活超时怎么算、异常怎么兜、参数怎么校验、上下文超了怎么压缩、工具跑一半断了怎么恢复、危险操作怎么拦。这些事做不扎实agent 再聪明也白搭这些事做扎实了哪怕模型一般般整体表现也会相当稳。我自己最大的转变就是从研究怎么让模型更聪明切换到研究怎么让系统更不容易崩效果立竿见影。1.2 一套 Harness 最少要有的六个零件不管你的 harness 前面挂的是哪个模型的名字底下这套骨架基本是通用的。我把它拆成六块这也是我后来重构时用的分层方式。模型接入层把不同提供方的接口归一化输入输出格式统一重试、限流、超时都在这一层处理。主循环驱动整个想一步、做一步、看结果、再想一步的过程负责终止条件和步骤上限。工具注册与执行工具怎么描述、参数怎么校验、执行怎么隔离、结果怎么截断。上下文管理token 预算怎么算、什么时候压缩、哪些信息必须钉住不能丢。权限门哪些操作可以直接做、哪些必须人工确认、路径和命令的边界在哪。观测与轨迹每一步的输入输出、耗时、token 消耗、失败原因全都要能回看。这六块里前两块是骨架中间两块是肉后面两块是保险。新手最容易忽略的是最后两块觉得能跑就行结果一旦出事连问题出在哪都定位不到只能靠猜。我在这上面栽的跟头最多后面会专门用一节讲。2. 个人 Harness 的整体设计与选型思路动手之前我想了很久要不要直接用现成的框架。市面上能拿来做这件事的东西不少抽象层次从低到高都有。我最后选择了自己手写一个薄薄的核心只在边缘用几个成熟的库。这个决定不是出于造轮子的执念而是算过一笔账之后的理性选择。2.1 为什么不直接用现成框架现成框架的第一个问题是抽象层太厚。它帮你处理了很多事情但当你需要知道我的消息到底是怎么被拼进上下文的工具调用的结果是被原样回填还是被改写过你就得一路往下翻源码翻到最后发现要改的地方在一个你根本不认识的中间层里。调试成本不是线性的是跳着涨的。第二个问题是生命周期不可控。很多框架默认了一套自己的会话管理、自己的存储方式、自己的并发模型。你想让它跟你的项目目录、你的日志系统、你的密钥管理对接就得写一堆适配代码写到最后适配代码比核心逻辑还多。这种时候我宁愿自己写核心把适配点压缩到两三个接口上。第三个问题是依赖重量。一个框架往往带进来几十个间接依赖升级一次可能牵动半条链。个人项目最怕的就是想改一行结果要先解决一堆环境冲突热情就是这么被磨没的。自己写核心的话依赖表能控制在五行以内升级风险几乎为零。当然也有反过来的情况。如果你只是想把一个想法快速验证一下两三个小时就想看到效果那直接用框架完全正确别跟我一样纠结。我的经验是验证阶段用现成的长期使用自己写薄的。这两个阶段的诉求根本不一样用同一套方案去覆盖反而处处别扭。顺带说一个我踩过的坑。我一开始想既要又要在现成框架外面套了一层自己的壳结果两边都在管状态出现了一个非常诡异的现象日志里显示工具调用了两次但实际只执行了一次。排查了大半天才定位到是两套状态机在抢方向盘。这个教训很值钱状态的所有权只能有一份要么全给它要么全给自己。2.2 核心模块划分与技术栈选型最终的目录结构是这样刻意保持扁平任何一个文件都能在三十秒内读完harness/ ├── core/ │ ├── runner.py # 主循环状态机 │ ├── context.py # 上下文构建与压缩 │ ├── executor.py # 工具执行、超时、异常兜底 │ ── gate.py # 权限门与路径沙箱 ├── providers/ │ ├── base.py # 统一接口定义 │ └── openai_like.py # 兼容主流协议的一个实现 ├── tools/ │ ├── fs.py # 读文件、写文件、列目录 │ ├── shell.py # 执行命令受控 │ └── repo.py # 搜索、diff、结构分析 ├── cli.py # 命令行入口 ── trace/ # 轨迹落盘目录技术栈选择了 Python 3.11 asyncio pydantic 一个轻量命令行库。理由逐条说。选 Python 而不是别的主要是生态问题。我要用的工具里很多都是围绕 Python 生态长的文件处理、代码解析、测试执行Python 这边最顺手。3.11 是因为异步性能和异常信息展示都有明显改善报错时会直接指到出错的那个表达式调试效率高不少。选 asyncio 是因为工具执行天然是并行的。模型一次可能吐出三个互不依赖的工具调用串行跑就是白白浪费时间。异步还有个隐藏好处超时控制和取消传播是原生支持的这在 harness 里太重要了。我需要能在任意时刻掐断一个跑飞的任务而不是等着它自己结束。选 pydantic 是为了参数校验。模型给的参数是不可信的缺字段、类型错、多给字段全都可能。用手写校验代码当然也行但一旦工具数量上去维护量就爆炸了。从函数签名自动生成 schema再自动校验这一层省下来的时间非常可观。存储上我没有引入数据库。会话状态就是一个 JSON 文件一步一落盘。文件小、可读、可 diff、可以直接扔进版本控制。等哪天真的需要并发写入或者复杂查询了再上数据库也不迟过早引入只会增加心智负担。2.3 一个关键取舍同步还是异步的权限确认权限确认这件事看起来是个小功能实际上牵扯到整个执行模型。我试过两种做法。第一种是把权限门做成同步阻塞的工具要执行前弹一个提示等人按键。好处是简单直接坏处是它会阻塞整个事件循环如果同时有别的任务在跑全部停摆。第二种是把权限门做成异步等待的需要确认的操作挂起把请求推到一个确认队列人在任何空闲时候处理处理完通过一个 future 唤醒。好处是不阻塞坏处是实现复杂度上去了而且你得处理人一直没确认的超时场景。我最后选了第二种但加了一个降级策略如果超过一定时间没人确认就自动拒绝并记一条日志把决定权交给默认更安全的选项。理由是个人使用场景下我经常是开着任务去干别的事不希望它死死卡在那儿等我回来点一下。而且超时即拒绝这个默认值比超时即放行要安全得多。这个原则我很推荐所有需要人工介入的地方超时后的默认值都应该是更保守的那个。3. 核心细节解析Harness 里最容易翻车的几处骨架搭起来之后你会发现真正决定成败的是几个非常具体的细节。这一节我把踩过的坑一个个摊开讲都是那种不写下来下次还会再踩的东西。3.1 主循环千万别写成递归这是我最想强调的一点。很多人写主循环的第一反应是递归模型给了工具调用就执行执行完再调自己一次。看起来优雅实际上问题一大堆。首先是栈深度。虽然大多数任务跑不了几百步但一旦某个任务陷入循环递归会直接把栈打爆报出来的错跟真实问题毫无关系排查方向全错。其次是不能中途快照。递归的中间状态全在调用栈里你想在第五步存个档、之后从这儿继续几乎做不到。第三是难以中断。外部要求停止时你得靠抛异常一层层往上穿每一层都要写好清理逻辑稍不注意就漏了资源没释放。我改成单层 while 循环加显式状态之后整个世界都清爽了async def run(self, task: str, session_id: str | None None) - str: state self.load_state(session_id) if session_id else None messages state.messages if state else self.init_messages(task) for step in range(self.max_steps): ctx self.context.build(messages, budgetself.token_budget) resp await self.client.chat(ctx, toolsTOOLS.describe()) messages.append(resp.as_message()) if not resp.tool_calls: return resp.content results await asyncio.gather( *(self.executor.execute(c) for c in resp.tool_calls) ) for call, result in zip(resp.tool_calls, results): messages.append({ role: tool, tool_call_id: call.id, content: result, }) self.trace.snapshot(session_id, messages, step) raise StepLimitExceeded(f超过最大步数 {self.max_steps})注意这里的max_steps是硬性上限不是建议值。我一开始觉得模型挺聪明的应该不会自己绕圈就没设上限。结果有一次它在一个改代码—跑测试—还是失败—再改的循环里跑了四十多分钟烧掉的钱够我吃好几顿饭。现在这个值我设成 25宁可贵一点人工接管也不要它无限制地空转。还有一个细节快照的时机。我一开始是每步都存后来发现文件写得太频繁而且大部分快照其实没用。改成只在工具调用之后存且相邻两次内容有实质变化才写盘磁盘压力小了很多同时恢复能力没受什么影响。3.2 工具调用的协议设计模型不是可信输入源工具这套东西最核心的认知是模型给的调用参数和用户在表单里填的内容一样不可信。它可能给出不存在的工具名可能漏掉必填参数可能把数字写成字符串也可能在 JSON 里塞进一段根本不合法的内容。你在写执行层的时候脑子里要一直绷着这根弦。先说 schema 怎么来。手写 JSON Schema 太累而且容易和函数签名不一致。我的做法是从函数签名自动生成附带把 docstring 当作工具描述传下去import inspect from typing import get_type_hints def tool(fn): sig inspect.signature(fn) hints get_type_hints(fn) params {} required [] for name, p in sig.parameters.items(): params[name] {type: python_type_to_json(hints.get(name, str))} if p.default is inspect.Parameter.empty: required.append(name) else: params[name][default] p.default TOOLS.register( namefn.__name__, description(fn.__doc__ or ).strip(), parameters{type: object, properties: params, required: required}, fnfn, ) return fn这里有个小心得docstring 的质量直接决定模型用得对不对。我一开始随手写读取文件结果模型经常传相对路径还带..。后来把描述改成读取项目根目录下的文件path 必须是相对于项目根目录的路径不允许使用..跳出根目录乱传的情况一下子少了很多。工具描述不是给人看的注释它是提示词的一部分要认真写。执行层的异常兜底我总结成永远不要让工具异常穿出去。工具抛出来的任何东西都应该被捕获、转成一段模型能读懂的文字、回填给它让模型有机会自己纠正。下面是我现在的执行器核心async def execute(self, call) - str: entry TOOLS.get(call.name) if entry is None: return f[错误] 不存在名为 {call.name} 的工具可用工具见工具列表。 try: args entry.validate(call.arguments) except ValidationError as e: return f[错误] 参数校验失败{e}。请修正后重试。 if not self.gate.allow(call.name, args): return [错误] 该操作被权限策略拒绝请换一种方式或说明理由。 try: result await asyncio.wait_for(entry.fn(**args), timeout30) except asyncio.TimeoutError: return [错误] 工具执行超过 30 秒被中断请缩小操作范围。 except Exception as e: return f[错误] 工具执行异常{type(e).__name__}: {e} return self.truncate(str(result))这段代码里有三处是我用血换来的。第一处错误信息要写成模型能理解的自然语言不要直接把 traceback 丢回去那会白白吃掉大量 token 而且模型往往抓不住重点。第二处超时时间要按工具类型区分读文件 5 秒够了跑测试可能要给到几分钟一刀切 30 秒是不合理的。第三处truncate必须有一次ls打出来几千行直接回填就把预算吃光了。注意错误回填的策略有个微妙之处。如果你把错误包装得过于友好模型有时会误以为操作成功了。我的做法是统一用[错误]前缀开头并且要求模型在收到错误后必须先解释原因再重试这样至少能看它在想什么。3.3 上下文管理真正决定能跑多远的地方前面那些都是能不能跑的问题上下文管理是能跑多远的问题。这一块我改过三版每一版都是被真实场景逼出来的。第一版是天真做法所有消息全留着。结果就是跑到十几步之后必然超预算要么报错要么被服务端截断表现就是模型突然失忆开始重复之前做过的动作。第二版是滑动窗口只留最近 N 条。这个方案的毛病也很明显最早的任务描述被挤掉了模型忘了自己要干什么开始瞎干。而且中间那些关键结论——比如这个文件里没有这个函数——一旦被滑掉它就会反复去查同一个文件。现在的第三版是钉头 摘要 保尾三段结构def build(self, messages, budget: int): if self.count(messages) budget: return messages head messages[:2] # system 提示 原始任务永不丢弃 tail messages[-8:] # 最近若干轮保留原始细节 middle messages[2:-8] summary self.summarize(middle) return head [ {role: system, content: f[历史过程摘要]\n{summary}} ] tailhead永远保留是因为任务目标一旦丢了整个执行就没有意义了。tail保留原始的完整消息是因为最近几步的细节对下一步决策最重要摘要会丢信息。中间那段交给模型自己总结成一段话压成几百 token。摘要的提示词我调了好几轮最后定下来是这句请把以下执行过程压缩成不超过 300 字的摘要必须包含 1. 已完成的关键步骤及结论哪些文件看过、发现了什么 2. 当前未解决的问题 3. 已经排除的方向避免重复尝试 不要保留原始工具输出内容只保留结论。第三条已排除的方向是我后来加的效果特别明显。之前模型经常在两三个死胡同之间来回横跳加上这条之后它至少知道这条路我试过了、不通。这里的关键词是结论而非过程——摘要是给未来的自己看的备忘录不是流水账。还有个细节token 计数不要自己写估算函数。不同模型的切分方式不一样估出来的数字偏差能到三成导致你要么过度压缩浪费预算要么压缩不足真超限。直接用提供方返回的用量信息或者接一个和模型配套的计数工具别偷这个懒。3.4 权限门不是不信任模型是不信任不确定性权限门这个东西很多人觉得是给不放心的人用的其实不是。它的本质是给不确定性划定边界。模型的输出是概率性的同样的输入两次可能给出不同的动作你没法保证它永远不越界所以边界必须由系统来守。我的权限门分三层从宽到严。第一层是路径沙箱。所有涉及文件路径的工具参数都要解析成绝对路径然后判断是否落在项目根目录之内。这一层拦的是无意中动了不该动的地方。class Gate: def __init__(self, root: Path, allow_write: bool False): self.root root.resolve() self.allow_write allow_write def allow(self, name: str, args: dict) - bool: if name in WRITE_TOOLS and not self.allow_write: return False raw args.get(path) or args.get(target) if raw: target (self.root / raw).resolve() if not target.is_relative_to(self.root): return False return True第二层是操作分级。读取类的操作默认放行写入类的默认需要显式开启。这个开关我在命令行里做成了启动参数日常只读排查时就不开写权限需要它改代码时再明确打开。这样心理负担小很多——你不用一直提心吊胆地盯着它。第三层是危险动作的人工确认。有一类操作我不想完全禁止但也不想让它自动执行比如批量删除、强制重置、大范围的格式化。这类操作我不去维护一张黑名单黑名单永远列不全而是反过来维护一张自动放行清单清单之外的写入操作一律走确认流程。这个白名单思路是我从运维那边学来的比黑名单可靠得多。提示路径检查一定要用resolve()之后再比较。直接比较字符串的话a/../b这种写法可以绕过大多数朴素检查。这个坑我踩过一次还好当时只是在一个测试目录里折腾没什么损失。4. 实操过程从零把最小可用 Harness 跑起来前面讲的都是设计层面的东西这一节讲怎么真正跑起来。我的建议是不要一上来就追求功能完整先做一个能读文件、能跑一条命令、能跑十步的最小版本跑通之后再往上加。这个顺序很重要因为很多设计问题只有在真跑起来之后才会暴露。4.1 从最小依赖开始搭骨架第一步是统一模型接入层。不管你用哪家服务目标都是把它归一化成两个方法一个是普通对话一个是带工具的对话。返回值我定义成一个固定结构后面所有代码只认这个结构不认原始响应。from dataclasses import dataclass, field from typing import Any dataclass class ToolCall: id: str name: str arguments: dict dataclass class Response: content: str tool_calls: list[ToolCall] field(default_factorylist) usage: dict[str, Any] field(default_factorydict) def as_message(self) - dict: msg {role: assistant, content: self.content} if self.tool_calls: msg[tool_calls] [ {id: c.id, type: function, function: {name: c.name, arguments: json.dumps(c.arguments)}} for c in self.tool_calls ] return msg这层抽象看起来多写了点代码但收益在后面换服务、加备用、做 A/B 对比都只改这一个文件。我后来试过三家不同的服务每次都只花十几分钟就能接上就是因为底下这层没变过。第二步是把最基础的三个工具写出来读文件、写文件、执行命令。别贪多这三个已经能覆盖八成场景了。写的时候严格遵守上一节说的规矩——描述写清楚、参数带约束、异常全兜住、结果要截断。第三步是主循环加命令行入口。这一步做完你就能跑一个真实任务了。我建议第一个测试任务就选最简单的那种比如找出项目里所有用到某个函数的地方汇总成一段说明。这类任务步骤少、结果可验证特别适合验证骨架。4.2 第一次跑通时遇到的三件事第一次跑通的记录我到现在还记得因为遇到的三个问题都非常典型。第一个问题是模型一直在说要调用工具但实际返回里没有工具调用结构。表现就是它输出了一段文字像我将读取 config.py 文件然后循环就结束了。原因是我在接入层里没有正确地把工具定义传下去模型根本不知道有这些工具可用只能靠文字描述自己的意图。这个坑的教训是工具定义没传对的时候模型不会报错它会装作一切正常。所以每次接入新服务第一件事是让它做一个必须调用工具才能完成的任务验证链路通了没有。第二个问题是死循环。它读了文件、没找到目标、又读一遍、又没找到。三次之后我就明白问题在哪了工具返回的内容长得像但实际不同比如带了很多无关行模型抓不住重点。解决办法是在读文件工具里加了一个可选的关键词过滤参数命中关键行才返回。这一步之后同样的任务从十几步降到了三四步。第三个问题是上下文超限。因为那个项目文件比较大一次读进来就吃掉了大半预算。解决方案是做更精细的结果截断按行数截、按字符数截、并且明确告诉模型内容已截断如需后续部分请指定行号范围。把截断这件事明说比偷偷截掉要好得多因为模型知道自己看到的是片段就会主动去要后续内容而不是基于残缺信息下结论。4.3 用 Harness 做自动化测试和代码审查骨架跑通之后我开始把它往实际工作上引。第一个场景是自动跑测试并修复失败用例第二个场景是代码审查。这两个场景的共通点是结果都是可验证的这就让整个循环有了明确的收敛条件。自动测试的循环是这样设计的。第一步harness 调用测试命令拿到输出。第二步把失败用例的完整输出喂给模型要求它给出修改建议。第三步如果是明确的修复就写入文件。第四步重新跑测试。循环的终止条件是全部通过或者连续两轮测试结果没有任何变化——后者说明它卡住了需要人来介入。这里的经验有两条。第一条是测试输出要做净化。原始输出里全是进度条、时间戳、随机哈希直接喂给模型既浪费 token 又干扰判断。我的做法是用正则把无关行过滤掉只保留失败用例的名称、断言位置和实际值。净化之后同样的失败信息输出长度能缩短到原来的十分之一。第二条是限制它的修改范围。我一开始允许它随意改任何文件结果有一次它为了让测试通过把测试文件本身改了。这是典型的作弊行为但模型不觉得这是作弊它只是找到了一个能达成目标的路径。后来我把测试目录设为只读修改只允许发生在源码目录内这个问题就消失了。不要指望模型理解你的意图要用物理边界去表达你的意图。代码审查场景我用的是另一套结构先算 diff把变更按文件分块每块单独让模型审一遍最后再汇总。为什么不一次性把整个 diff 丢进去因为大 diff 会稀释注意力模型往往抓住前面几处问题就草草收尾后面的文件基本没看。分块之后每块都有独立的问题清单之后再合并去重召回率明显更高。审查的提示词我固定了几个维度逻辑正确性、边界条件、异常处理、命名与可读性、是否有重复实现。要求每条问题必须给出文件位置和具体行号不给具体位置的意见一律丢弃。这条规则过滤掉了大量泛泛而谈的输出留下的都是能直接改的东西。4.4 轨迹落盘出事之后能不能查是关键我强烈建议从第一天就做轨迹落盘哪怕只是往 JSON 文件里追加。因为你迟早会遇到它明明说做了但结果不对的情况这时候唯一的办法就是回看它到底做了什么。我落盘的内容包括每一步的完整消息列表、工具调用参数、工具返回内容截断后的、耗时、token 用量、以及失败类型。文件名按会话 ID 加时间戳命名。这些信息看起来不起眼但排查的时候价值极高。有一次我怀疑是模型判断失误翻了轨迹才发现是工具返回了一个格式正确但内容为空的结果模型基于空做了推理。问题在工具端不在模型端。没有轨迹这个结论根本得不出来。5. 常见问题与排查技巧实录这一节是我用得最久的一部分内容很多都是文档里不会写、只有自己撞过才知道的东西。5.1 问题速查表现象大概率原因处理办法只说要做某件事但不产生工具调用工具定义未正确下发或描述过于模糊检查工具列表是否随请求发送把工具描述写具体反复调用同一工具、参数几乎一样上下文里的历史结论被压缩掉了检查摘要策略确保已排除方向被保留单次任务烧掉大量额度没有步数上限或陷入了失败重试循环设置硬性步数上限对连续失败做熔断上下文突然超限大文件或长输出未经截断直接回填加结果截断明确告知模型内容不完整任务重启后从头开始状态未持久化或快照点太少每步落盘恢复时校验状态版本修改了不该改的文件权限门缺失或白名单过宽引入路径沙箱写操作默认关闭工具长时间无响应整体卡住缺少超时控制或同步等待阻塞了事件循环所有工具调用加超时权限确认走异步报错信息看不出问题异常被吞掉或 traceback 直接回填统一错误格式保留原始异常到轨迹文件输出前后矛盾中间摘要丢掉了关键结论摘要提示词里强制要求保留结论与排除项同样的任务结果不稳定温度参数过高或工具执行有并发副作用降低随机性确保工具幂等这张表我贴在显示器边上很久后来问题少了才收起来。表里最值得展开的是熔断那一条。连续失败重试是 token 消耗的最大杀手因为模型每次都觉得自己在改进实际上是在同一个坑里打转。我的做法是同一个工具的连续失败达到三次就强制中断并交还人工同时在状态里记一笔恢复时先把这个失败信息带进去。5.2 几条不写在文档里的经验除了表格里那些还有几条是我自己总结出来的跟具体报错无关但影响很大。第一条给模型留一条诚实的失败出口。系统的提示词里我明确写了如果某个任务在当前工具集下无法完成请直接说明缺少什么能力不要用近似的方式伪造结果。 加上这句之前模型在缺少工具时会编造输出加上之后它会直接说我没有执行命令的能力。这一句提示词的价值比加三个工具都大。第二条日志的详细程度要大于你的耐心。我一开始嫌日志太多太吵只记关键节点。后来发现最需要的恰恰是那些看起来无关的中间状态。现在我的原则是轨迹文件里记全控制台只打印摘要。你需要的是可回查不是可读屏。第三条不要在没有幂等保证的情况下开启并发。我做过一个实验让两个写文件的操作并行执行。理论上它们操作不同文件没有冲突但实际出现了内容串位。原因是两个操作共享了同一个临时文件名。这个 bug 排查了很久因为它不是每次都出现。后来我定了个规矩只有读操作允许自由并发写操作一律串行。性能损失可以接受确定性更重要。第四条工具数量控制在十个以内。我试过把工具加到二十多个结果模型的选择质量明显下降经常挑一个不太合适的工具去做本可以用另一个工具更高效完成的事。工具不是越多越好多了就是噪声。我的做法是把不常用的能力合并成一个带子命令的工具而不是拆成十几个平级工具。第五条定期回放旧的轨迹。我大概每个月会挑几条早期的失败轨迹重新看一遍往往能发现当时没意识到的问题。比如有一次我发现一个看似是模型判断失误的案例实际上是上下文里的时间戳格式不一致导致的误读。这个发现后来促使我在回填结果时统一了格式。回放的价值在于你带着现在的理解去看过去的失败视角完全不同。最后分享一个我自己觉得挺有用的小设计在每次任务结束时让模型自己写一句这次任务中我遇到的最大障碍是什么。这句话会存进轨迹文件。攒了几十条之后我按词频统计了一下发现排第一的是文件内容截断后不知道还剩多少。于是我给所有涉及大内容的工具都加上了总行数 / 已返回范围的元信息。问题基本就消失了。这个思路说白了就是让使用者自己报告痛点比自己猜要准得多。