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, budget=self.token_budget) resp = await self.client.chat(ctx, tools=TOOLS.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( name=fn.__name__, description=(fn.__doc__ or "").strip(), parameters={"type": "object", "properties": params, "required": required}, fn=fn, ) 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), timeout=30) 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_factory=list) usage: dict[str, Any] = field(default_factory=dict) 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 排查了很久,因为它不是每次都出现。后来我定了个规矩:只有读操作允许自由并发,写操作一律串行。性能损失可以接受,确定性更重要。
第四条:工具数量控制在十个以内。我试过把工具加到二十多个,结果模型的选择质量明显下降,经常挑一个不太合适的工具去做本可以用另一个工具更高效完成的事。工具不是越多越好,多了就是噪声。我的做法是把不常用的能力合并成一个带子命令的工具,而不是拆成十几个平级工具。
第五条:定期回放旧的轨迹。我大概每个月会挑几条早期的失败轨迹重新看一遍,往往能发现当时没意识到的问题。比如有一次我发现,一个看似是模型判断失误的案例,实际上是上下文里的时间戳格式不一致导致的误读。这个发现后来促使我在回填结果时统一了格式。回放的价值在于,你带着现在的理解去看过去的失败,视角完全不同。
最后分享一个我自己觉得挺有用的小设计:在每次任务结束时,让模型自己写一句"这次任务中我遇到的最大障碍是什么"。这句话会存进轨迹文件。攒了几十条之后,我按词频统计了一下,发现排第一的是"文件内容截断后不知道还剩多少"。于是我给所有涉及大内容的工具都加上了"总行数 / 已返回范围"的元信息。问题基本就消失了。这个思路说白了就是让使用者自己报告痛点,比自己猜要准得多。