☰
Agent Harness 工程实践:工具调度、上下文裁剪与可观测性
2026/11/4 13:17:43 网站建设 项目流程

Agent Harness 这个词有人译成“挽具”,有人叫“马具层”,我更愿意把它理解成套在模型外面那副缰绳和车架:模型负责出主意,Harness 负责把主意变成动作、把动作的后果收回来、再决定下一步。它管的东西很具体——工具怎么注册、调用怎么派发、上下文怎么裁剪、错了怎么回灌、跑飞了怎么刹车、整个过程怎么留下可复盘的记录。

说白了,一个 Agent 好不好用,三分看模型,七分看这副架子。模型能力是水,Harness 是渠;渠修得歪,水再大也浇不到地里。这套东西适合谁看?做过几轮对话 Demo、想让模型真正去查数据、改文件、跑命令的开发者;也适合正在评估“自研还是用现成编排库”的技术负责人。下面这些内容,是我自己反复搭、反复拆之后攒下来的东西,有架构层面的取舍,也有凌晨三点对着日志找 bug 的具体细节。

1. Agent Harness 到底卡在哪个环节

1.1 从“会聊天”到“能干活”,中间隔着一整套工程

很多人第一次做 Agent,写出来的东西大概长这样:把工具说明塞进提示词,让模型输出一段 JSON,正则抠出来,eval或者json.loads一下,调函数,把结果拼回消息列表,再请求一次。跑通了,很有成就感。然后拿真实任务一试,立刻崩:模型偶尔输出带 Markdown 代码块的 JSON,偶尔在 JSON 前后加一句“好的,我来帮你查”,偶尔编一个不存在的工具名,偶尔把日期参数写成“明天”这种自然语言。

这些问题单看都是小毛病,堆在一起就变成一座山。Harness 存在的第一层理由,就是把这些“小毛病”变成可枚举、可拦截、可恢复的工程问题。模型输出不规范?解析器做容错和修复。工具名不存在?注册表直接拒绝并回灌可用列表。参数类型不对?在调用前做一次 schema 校验,把校验错误当成工具结果返回给模型,让它自己改。这一圈下来,成功率能从六成提到九成以上,而模型一个字都没换。

第二层理由是状态。对话是无状态的请求-响应,Agent 是有状态的执行过程。它需要记住“我已经查过订单表了,结果是空的”“上一步的超时是网络问题不是权限问题”“用户三分钟前说过不要动生产库”。这些记忆不在模型权重里,只在 Harness 的运行时对象里。你把这块设计好了,Agent 才谈得上“连贯”;设计不好,它每一步都像刚失忆。

第三层理由是边界。让模型去执行命令、写文件、发请求,本质上是把一个不确定的输出接到一个确定的副作用上。这中间必须有闸门:哪些工具是只读的、哪些要二次确认、单次调用超时多少、单任务最多烧多少 token、失败几次强制中止。这些不该指望模型自觉,得由 Harness 硬性兜住。

1.2 Harness、提示词、编排库、模型,四者别搅在一起

我见过不少项目把这几件事混成一锅粥,出问题就到处改,改到最后连自己都不知道是哪一层生效了。实际的责任划分应该很清楚,我画不出图,就用一段话来描述:

层次负责什么出问题时的典型症状
模型理解意图、生成决策、选择工具、组织语言答非所问、选错工具、参数瞎编
提示词约束角色、给出领域知识、说明输出格式格式跑偏、越权回答、遗漏关键步骤
Harness循环控制、工具派发、上下文管理、错误处理、安全闸门、日志死循环、上下文爆掉、错误不恢复、副作用失控
编排库提供上述能力的现成实现与抽象抽象过度、调试黑箱、版本升级破坏兼容

关键认知是:提示词解决不了循环问题,Harness 也救不了模型不会做的事。你要是发现 Agent 老是重复调用同一个工具,别急着改提示词写“请不要重复调用”,八成是你的 Harness 没在工具结果里明确告诉它“这次调用失败了,原因是 X,你可以试试 Y”。信息没回灌到位,模型只能靠猜,猜就只能瞎转。

还有一点值得说:编排库不是必需品。我做过一个中等复杂度的数据处理 Agent,全套 Harness 手写,一千二百行左右,没有引入任何编排依赖。好处是每一行都能调试,出问题打开日志就知道在哪;坏处是要自己处理并发、重试、序列化这些琐事。如果你的场景工具不超过二十个、流程不超过五步,我真心建议手写,比读别人的抽象源码快得多。反过来,如果你的 Agent 需要多智能体协作、复杂的人类介入审批、跨会话持久化,那用成熟编排库更划算,别重复造轮子。

1.3 什么时候你其实不需要 Harness

这句话可能有点反直觉,但我还是要说:不是所有接大模型的地方都需要 Harness。如果你的需求是“用户问一句,模型答一句,最多查一次知识库”,那这就是一个 RAG 问答,不需要循环、不需要工具注册表、不需要预算控制。硬套一个 Agent 架构上去,只会让延迟翻三倍、成本翻两倍、故障点翻五倍。

判断标准我用一条:任务是否能被拆成“若干步,且下一步做什么取决于上一步的真实结果”。是,就需要 Harness;不是,就老老实实写一条直线流程。很多团队栽在“为了 Agent 而 Agent”上,做出来的东西用户点一下要等四十秒,中间还时不时卡住,体验远不如一个三秒返回的固定流程。

2. 架构设计:把不确定性关进可控的笼子

2.1 五个模块的职责切分

我自己的 Harness 不管多简单,都会拆成这五块,缺一块后面就要还债:

第一块是循环控制器。它只干一件事:决定“继续”还是“停”。继续的条件是模型返回了工具调用意图且预算没耗尽;停的条件是模型给出了最终答复、或者步数到顶、或者 token 到顶、或者墙钟时间到顶。这块代码应该短到二十行以内,任何超过五十行的循环控制器都说明你把业务逻辑塞进去了,那是坏味道。

第二块是工具注册表。每个工具要有名字、描述、参数 schema、风险等级、超时时间、结果截断策略。注册表是唯一的真相来源,模型的工具清单、执行器的派发逻辑、日志里的工具名,全部从这里读。我踩过的最蠢的坑是工具清单在两处硬编码,改了一处忘了另一处,模型看到的和实际能调的列表不一致,排查了两个小时。

第三块是上下文管理器。它负责每一轮请求前组装消息数组:系统提示、历史消息、工具结果、必要的摘要。还得负责在接近窗口上限时做裁剪。这块是整个 Harness 里最容易出隐蔽 bug 的地方,因为超限往往不报错,只是效果悄悄变差。

第四块是执行器与安全闸门。执行器负责真正调用工具,包括超时控制、异常捕获、结果序列化、大结果截断。闸门负责在调用前检查权限:这个工具在当前会话里允许吗?参数里有没有危险路径?需不需要人工确认?闸门和执行器最好分开写,因为闸门是策略,执行器是机制,混在一起以后想加一条新策略就得动核心代码。

第五块是可观测层。每次模型请求、每次工具调用、每次预算变化,都要落下结构化记录。这块我一开始觉得是“锦上添花”,后来发现它是唯一能在事后说清“到底发生了什么”的东西。没有它,Agent 就是个黑箱,用户说“它刚才乱删东西了”,你连它调了哪个工具都查不出来。

2.2 为什么我坚持用显式状态机而不是递归

早期我写过一个递归版本的 Agent:工具调用结果回来,函数自己再调自己一次。代码很优雅,三十行搞定。然后线上出了问题——调用栈深到四十层的时候爆了,而且因为递归,中间任何一层抛异常都会把整个链路的中间状态丢掉,日志里只剩下一个孤零零的报错。

改成显式循环之后,好处立刻显现。状态全在一个state对象里:messages、step、budget、tool_calls_history、last_error。循环体是平的,每一步都能打点、能断点、能在任意一步中断后从磁盘恢复。想加“跑到第十步的时候插一句人类确认”,只要在循环体里加个判断;想加“某类工具连续失败三次就换策略”,也是加个计数器的事。

显式状态机还有个大好处是可测试。你可以 mock 一个模型客户端,让它依次返回预设的响应序列,然后断言 Harness 在第几步做了什么调用。这种测试写起来很枯燥,但它能挡住 80% 的回归问题,尤其是你改上下文裁剪逻辑的时候。

# 状态对象的骨架,字段名我习惯这么起 @dataclass class RunState: run_id: str messages: list # 完整消息历史 step: int = 0 budget: Budget = None tool_history: list = field(default_factory=list) # 每次调用的记录 consecutive_failures: dict = field(default_factory=dict) # 按工具名计数 finish_reason: str | None = None

2.3 终止条件:三重刹车,一个都不能省

只靠“模型自己说完成了”来终止,是新手最容易犯的错。模型有时候会陷入一种奇怪的循环:查一次、没查到、换个说法再查、还是没查到,一直转到天荒地老。我在一个内部工具上见过它连续调了六十多次同一个搜索接口,账单上那一晚的数字挺好看的。

我的做法是三重刹车同时装:

步数刹车。max_steps一般设 15 到 40,看任务复杂度。超过就强制停,把当前已有的信息整理成答案返回,并在结果里标注“达到步数上限,结论可能不完整”。这个标注很重要,它让下游知道这份结果的可信度打了折扣。

预算刹车。按 token 算,不是按钱算,因为钱会随价格变,token 是稳定的。核心思路是给整次运行设一个总量,每次模型请求后累加,超了就停。具体数值怎么定,后面第 4 节我会给一个估算方法。

墙钟刹车。max_seconds对付的是“某次工具调用卡住”这种场景。注意这个刹车必须配合工具级超时一起用,光有总时长不够——如果第 3 步的超时是 30 秒,总共 600 秒,那你最多也就被卡 20 次,但如果单次工具不设超时,一次卡死就直接把整个运行挂在那里。

# 三重刹车,注意返回的是"哪个刹车踩了",不是布尔值 def check_brake(state, cfg): if state.step >= cfg.max_steps: return "max_steps" if state.budget.used_tokens >= cfg.max_tokens: return "token_budget" if time.time() - state.budget.started_at >= cfg.max_seconds: return "wall_clock" return None

这里有个细节值得单独说:刹车踩下去之后怎么收尾。直接抛异常是最差的做法,用户体验是“转了半天报错了”。正确做法是把finish_reason写进状态,让模型做最后一次“无工具”的请求,提示它“你已经不能再调用工具了,请基于现有信息给出结论”。这个降级路径能让 80% 的“预算耗尽”变成一份虽然不完美但可用的答案。

3. 核心细节:工具层和上下文层的成败在此

3.1 工具描述写不好,后面全是白费

工具描述是模型选工具的唯一依据。我见过很多团队在描述上极其敷衍,写一句“查询数据”,然后抱怨模型老是选错工具。这就像给新员工一份岗位说明书,上面只写了“干活”两个字。

我的描述模板固定包含四段:做什么、什么时候用、什么时候别用、返回什么。举个例子,一个查订单的工具:

  • 做什么:按订单号查订单详情,只读。
  • 什么时候用:用户明确给出了订单号,或上一步工具返回了订单号。
  • 什么时候别用:用户只给了手机号或姓名,此时应先用另一个按用户查订单的工具。
  • 返回什么:包含状态、金额、创建时间的对象;查不到时返回{"found": false}。

关键是第三段“什么时候别用”。这一句能挡掉大量误调用,因为它直接切断了模型最常见的推理捷径——“看到查询类需求就挑一个名字像的工具”。

参数 schema 上,我有几条硬规则:能枚举的绝不放自由字符串。状态字段就写enum: ["pending", "paid", "shipped"],别让它自己编。日期一律用YYYY-MM-DD格式并在描述里写明,别让模型自己解释“上周”。每个参数都写 description,哪怕名字已经很清楚,因为模型对参数的理解主要来自描述而不是名字。

注意:工具数量超过 15 个之后,选错率会明显上升。这不是模型笨,是清单太长导致的注意力稀释。解法是分组——把工具按领域分成几组,先让模型选组,再在组内选工具。这本质上是给模型做了一次路由,代价是多一次请求,换来的是准确率提升。

3.2 上下文管理:真正的难点是“该扔什么”

上下文窗口现在动不动就上百 K,很多人觉得够用了就不用管。这是个陷阱。窗口大不代表效果好,中间部分的信息被稀释是真实存在的现象,你把二十万 token 塞进去,模型对中间那段的注意力确实会掉。更要命的是成本——每一轮都重发全量历史,token 消耗是随步数平方级增长的。

我的上下文组装策略分三层:

永久层:系统提示、核心规则、工具清单。这层不动,但工具清单本身要精简,描述控制在两行以内,详细说明放第 3.1 节那种四段式里,但只有被选中时才展开。实际上我常用的是“先给简表,模型选了组之后再给详细 schema”。

滑动层:最近 N 轮完整消息。N 一般取 6 到 10,覆盖当前任务的直接上下文。这一层保留原始格式,包括工具调用的入参和返回。

摘要层:更早的历史压成一段文字摘要。摘要不是让模型自己总结,那样不可控;我一般在步数达到阈值时,用一次额外的模型请求生成,提示词固定为“用不超过 200 字记录已完成的操作、得到的关键结论、尚未解决的问题”。摘要的质量直接决定长任务能不能跑完,所以这个提示词值得反复调。

还有一条容易被忽略的:工具结果必须截断。一个返回两百行 JSON 的接口,塞进上下文能吃掉几万 token,而且后面九成的行模型根本用不上。我的默认策略是超过 2000 字符就截断,保留头部和尾部,中间用...[truncated N chars]...标记,同时在结果里附一句“结果已截断,如需完整内容请用更精确的参数重新查询”。最后这句话是关键,它给了模型一个自救的路径。

3.3 错误回灌:让模型自己修,但别让它瞎修

工具调用失败是常态,网络抖动、参数非法、权限不足、目标不存在。失败的返回怎么组织,直接决定 Agent 是“自我修复”还是“死循环”。

我的错误返回固定三段结构:

{ "ok": false, "error_type": "invalid_argument", "message": "参数 order_date 格式不合法,期望 YYYY-MM-DD,收到 '上周三'", "hint": "如果是相对时间,请先根据当前日期 2025-03-14 换算成绝对日期" }

error_type是用来做程序化判断的——比如invalid_argument允许重试,permission_denied直接终止整个任务,not_found允许换查询条件重试一次。hint是给模型的自然语言提示,写得好,模型一次就能改对;写得差,它就在同一个地方反复撞墙。

还有一条硬规则:同一个工具、同一类错误,连续失败三次就熔断。熔断后不再把该工具放进可选清单,并在上下文里明确写“工具 X 已因连续失败被禁用,请用其他方式完成任务或直接告知用户”。没有这条,你会看到模型像撞玻璃的苍蝇一样重复同样的调用。

4. 实操:从零搭一个能用的最小 Harness

4.1 目录结构与最小依赖

我不喜欢一上来就上重框架,先把骨架立起来,跑通一件事再说。目录大概是这个样子:

agent/ main.py # 入口,组装配置 loop.py # 循环控制器 tools.py # 工具注册表 + 执行器 context.py # 上下文组装与裁剪 budget.py # 预算与刹车 trace.py # 结构化日志 sandbox.py # 权限闸门与路径校验

依赖我尽量压到最少:一个 HTTP 客户端、一个分词器(如果模型服务端不返回精确 token 数的话)、标准库的json、time、uuid、pathlib。不引入异步框架,第一版就用同步,简单可靠。等确认瓶颈在并发上再改,别提前优化。

4.2 主循环:三十五行的核心

def run(user_input: str, cfg) -> str: state = RunState( run_id=str(uuid.uuid4()), messages=[{"role": "user", "content": user_input}], budget=Budget(max_steps=cfg.max_steps, max_tokens=cfg.max_tokens), ) stage = "prefill" # 支持断点恢复 while True: brake = check_brake(state, cfg) if brake: state.finish_reason = brake return finalize_without_tools(state, cfg) # 降级收尾 payload = build_request(state, cfg) # 上下文组装 resp = call_model(payload, cfg) # 带重试的请求 state.budget.used_tokens += resp.usage.total state.step += 1 if not resp.tool_calls: state.finish_reason = "model_done" return resp.content state.messages.append(resp.as_assistant_message()) for call in resp.tool_calls: result = dispatch(call, state, cfg) # 闸门 + 执行 + 截断 state.messages.append({ "role": "tool", "tool_call_id": call.id, "content": json.dumps(result, ensure_ascii=False), }) state.tool_history.append({"step": state.step, "call": call.name, "args": call.args, "ok": result.get("ok")}) trace.flush(state, cfg)

这段代码里有几个点值得单独说。

call_model必须带至少一次重试,且要区分可重试错误(超时、5xx、限流)和不可重试错误(400 参数错、内容审核拒绝)。对限流错误要读服务端返回的重试等待时间,不要自己瞎指数退避,容易撞在同一个窗口上。

dispatch里,工具结果是逐条追加的,不是批量追加的。因为一轮里模型可能并行发起三个调用,结果必须按tool_call_id一一对应上。这个对应关系错一次,后面整条链路都会用错数据,而且不会报错,只是结论莫名其妙。我在这个坑里待过整整一个下午。

trace.flush放在每轮末尾,而不是整个运行结束。原因是运行可能被强杀,结束时的 flush 就丢了。每轮落一次,最多丢一轮,代价可接受。

4.3 工具注册与执行器:把“防御”写进装饰器

TOOLS = {} def tool(name, desc, schema, risk="read", timeout=20, max_chars=2000): def deco(fn): TOOLS[name] = { "fn": fn, "risk": risk, "timeout": timeout, "max_chars": max_chars, "spec": {"name": name, "description": desc, "parameters": schema}, } return fn return deco @tool( name="read_file", desc="读取工作区内的文本文件。仅限 sandbox 目录;不支持二进制。" "仅在明确知道文件路径时使用;不知道路径请先用 list_files。", schema={"type": "object", "properties": {"path": {"type": "string", "description": "相对 sandbox 的路径,如 data/report.csv"}}, "required": ["path"]}, risk="read", timeout=5, max_chars=4000, ) def read_file(path: str) -> dict: ...

执行器的核心逻辑:先校验 schema,再过权限闸门,再带超时执行,再截断结果,最后统一包装成三段式返回。任何一步失败都不抛异常,都返回{"ok": false, "error_type": ..., "message": ..., "hint": ...}。整个 Harness 里只有最外层的主循环允许抛异常,工具层一律把异常吃掉、翻译成结构化错误。这条纪律能让你在日志里看到完整的调用链,而不是一个断在半路的堆栈。

4.4 权限闸门:三类操作三种待遇

我把工具按副作用分成三类,待遇完全不同:

风险等级典型工具处理方式
read读文件、查数据库、搜索直接执行,只做路径/范围校验
write写文件、发消息、改配置需要 dry-run 预览,记录变更前后差异
dangerous执行命令、删除数据、调外部支付强制人工确认,默认不注册进工具清单

最容易出事的是 write 类。我的做法是让每个 write 工具都支持dry_run=True参数,先返回“将会做什么”,由 Harness 判断是否需要确认。对本地开发场景,可以配置成“工作区内自动放行,工作区外必须确认”;对生产场景,一律确认。这个分级不是拍脑袋,是按“最坏情况下损失能不能撤销”来划的——能撤销的放行,不能撤销的拦下来。

路径校验我单独写了一个函数,比想象中麻烦:要防../穿越,要防软链接指向外面,要防符号注入。最简单的可靠做法是Path(p).resolve()之后判断是不是在允许的前缀下,不要自己写字符串匹配。

4.5 预算估算:一个能用的经验公式

token 预算别拍脑袋。我给一个可落地的估算流程。

先算单次请求的固定开销:系统提示 + 工具 schema 清单。工具 schema 是 JSON,一个中等复杂度的工具大约 150 到 300 token,二十个工具就是 3000 到 6000 token。这部分每一轮都要重发,是纯固定成本。所以工具数量对成本的影响是乘法级的,精简工具清单不只是为了准确率,也是为了钱。

再算单步增量:一次工具调用约 200 token(调用意图 + 参数),工具结果按截断上限算,2000 字符大约 1200 到 1800 token(中文密度高,取上限)。所以单步增量约 1500 到 2000 token。

那么总预算大概是:固定开销 × 步数 + 增量 × 步数。以 20 步、固定开销 4000、增量 1800 算,大约是 11.6 万 token。这个数字意味着一件事:如果不做上下文裁剪,20 步的任务根本跑不进常见窗口。裁剪不是优化项,是必需项。

中文的 token 估算我再给一条粗略换算:一个汉字在不同分词器下大约是 0.6 到 1.2 个 token,我保守按 1 算;英文按 4 字符 1 token 算。这个系数一定要用真实分词器校准一次——拿一段你自己的典型文本,跑一遍分词器数一下,误差可能到 30%。校准一次花五分钟,能省下后面无数次“明明没超怎么报错了”的困惑。

另外提醒一句:max_tokens输出限制要和工具调用一起考虑。如果输出上限设得太小,模型可能在生成工具调用 JSON 的中途被截断,回来的是一个不完整的 JSON,解析失败。我遇到过输出上限 256 导致参数被砍掉一半的情况,表现为“随机性的参数丢失”,非常难查。稳妥值至少 1024,涉及长参数的工具给到 2048。

5. 可观测性:没有 trace 的 Agent 等于闭眼开车

5.1 每次运行该落哪些字段

我吃过的最大的亏,是一个线上 Agent 偶尔给出错误结论,但日志里只有“请求成功”和“请求成功”。花了半天复现不出来。从那以后我把 trace 的字段固定下来,一条都不省:

字段为什么必须有
run_id / step把散落的日志串成一次运行
模型请求的完整消息数组(脱敏后)唯一能还原“模型当时看到了什么”的东西
模型原始输出排查解析失败、格式跑偏的唯一依据
工具名 + 参数 + 返回(截断)定位是模型选错还是工具返回错
每步 token 消耗与累计找出哪一步在烧钱
耗时分解(模型 / 工具 / 排队)判断延迟瓶颈在哪
finish_reason区分“正常完成”和“被刹车”

其中“模型请求的完整消息数组”是最有价值的,也是最多人省掉的。因为当模型给出一个莫名其妙的答案时,你唯一能做的判断就是“它当时看到的上下文里到底有什么”。上下文裁剪逻辑的一个 bug,可能就是把它刚查到的关键数据裁掉了,而模型只能基于残存信息瞎猜。没有这条记录,你永远不知道。

我一般把一次运行的 trace 写成一个 JSONL 文件,一行一个事件,run_id作为文件名。这样既方便 grep,也方便整批拉出来做统计分析。

5.2 评测集怎么攒:从真实失败里长出来

Agent 的评测不能用通用榜单,因为你的工具、你的领域、你的失败模式都是独有的。我的做法很土但有效:建一个cases/目录,每发现一个真实失败,就把它固化成一条用例。

一条用例包含:用户输入、期望的关键行为(比如“必须调用 read_file 且路径正确”)、不允许的行为(比如“不得调用任何 write 类工具”)、以及最终答案里必须包含的关键信息。不追求精确匹配答案文本,只断言行为和关键事实。

跑到一定规模(我一般攒到 50 条左右就开始有统计意义了),每次改 Harness 或者换模型,都跑一遍。这时候你会看到一个很有意思的现象:改上下文裁剪策略,会同时影响工具选择的准确率和答案的完整性,两个指标经常反向变化。没有这套用例,你根本不知道自己是改好了还是改坏了,只能凭感觉。

提示:评测用例里一定要有几条“不该动手”的用例。比如用户问“你能帮我删掉旧文件吗”,期望行为是先询问确认,而不是直接删。这类用例能挡住最危险的一类回归。

6. 常见问题与排查实录

6.1 故障速查表

症状最可能的原因排查动作处理
同一工具被反复调用失败信息没回灌,或 hint 缺失看 trace 里工具返回是否有ok:false和hint补全错误结构,加连续失败熔断
跑到一半答案突然变差上下文被裁掉了关键信息对比裁剪前后的消息数组调整摘要触发阈值,关键结果置顶保留
工具参数随机缺失输出 token 上限太小,JSON 被截断查模型原始输出末尾是否完整输出上限提到 1024 以上
延迟突然从 5 秒涨到 40 秒工具串行执行,或某工具超时未设看耗时分解独立工具改并行,全部设超时
偶尔报 JSON 解析失败模型加了 Markdown 包裹或前后缀说明看原始输出解析器先剥代码块,再做括号配对截取
token 消耗远超预算估算工具返回未截断统计单次工具结果字符数加截断 + 提示模型用更精确参数重查
模型调用不存在的工具工具清单与实际注册表不一致对比两处清单统一从注册表生成,禁止硬编码
结论里出现编造的数据工具返回found:false但没明确提示看返回结构空结果也要给明确语义,不要返回空对象

6.2 几个反直觉的坑

第一个坑:给模型看的工具越多,它越容易选错,但也越不容易说“我不会”。听起来矛盾,实际是两回事。工具多的时候,它总能找到一个“看起来像”的工具硬用,而不是老老实实说“这个我做不了”。所以工具清单要窄,宁可让它说不会,也不要让它用错工具给出错误答案。错误答案比没有答案危害大得多。

第二个坑:系统提示写得越长,效果不一定越好。我试过把一份两千字的规范塞进系统提示,结果模型开始忽略其中一半。后来拆成“每次只在需要时注入相关的那一段”,效果反而好。上下文里塞的东西,模型不是均匀关注的,长提示会稀释关键指令。这是我在实际项目里反复验证过的。

第三个坑:重试有时候比不重试更糟。对于 write 类工具,如果调用超时了,你不知道它到底执行了没有。这时候盲目重试可能导致重复写入。正确做法是给 write 类工具设计幂等键——每次调用带一个从run_id + step + 工具名派生的唯一键,服务端据此去重。没有幂等保证的写操作,超时后应该直接终止并报告,而不是重试。

第四个坑:并行工具调用会打乱结果顺序。模型一轮里发起三个调用,返回的顺序不保证和请求一致,必须靠tool_call_id匹配。我见过有人用数组下标硬对应,本地测试一直没问题(因为串行执行恰好顺序一致),上线换并行就全乱了。

第五个坑:temperature=0不代表完全确定。同一个输入多次运行,工具选择仍可能出现差异。所以“我本地跑十次都对的”不能作为上线依据。要拿评测集批量跑,看通过率而不是单次结果。这一点在做回归测试的时候特别重要,单次通过率 90% 的东西,连续跑五步全对的概率只有 59%。

6.3 一个真实排查过程

有个做数据整理的 Agent,症状是“偶尔漏掉最后一列的数据”。不是每次都漏,大概十次里有三次。日志里工具调用都成功,返回的数据也完整。

我第一步先把 trace 拉出来,对比成功和失败的运行。发现一个规律:失败的运行里,读取数据的工具返回内容都超过了某个长度。第二步去看上下文组装逻辑,果然——那个工具的结果没有被截断,而是整段塞进去,导致后面的步骤触发了摘要压缩,而摘要提示词里写的是“记录关键结论”,模型把“完整的表格数据”当成了可以概括的东西,摘要成了“读取了一份包含用户信息的表格”。原始数据就这么没了。

修复方案有两条:一是让摘要提示词明确写“不要概括原始数据,只记录数据来源和字段名”,二是给大结果加一个“暂存区”——超过阈值的工具结果不直接进上下文,而是存到本地文件,上下文里只放“结果已存至 tmp/xxx.json,共 N 行,字段为 [...],需要时用 read_file 分页读取”。第二条更根本,因为它把“上下文容量”和“数据量”解耦了。这个模式后来我在好几个项目里复用,效果都很稳。

7. 几条踩过之后才明白的经验

第一条,先做能跑通的最笨版本,再谈优化。我最早搭 Harness 的时候,花了三天设计一套“可插拔的工具中间件体系”,结果还没跑通一个完整的任务。后来全部推倒,用最直白的if/else加字典重写,两个小时就跑起来了。中间件那套东西,等你有五个以上工具、三种以上横切逻辑(日志、限流、鉴权)之后再上,才有意义。

第二条,把“不可控”的部分尽量往边缘挤。模型的输出不可控,那就把它挤到最边缘——进来之后立刻结构化、校验、翻译成内部对象,后面所有逻辑都只跟确定性对象打交道。工具的执行不可控,那就给它套超时、套截断、套幂等键,把不确定性关在一个小盒子里。盒子越小,整个系统的可预测性越高。

第三条,别指望一次把提示词调到完美。我现在的做法是把提示词当成代码来管理:分文件、有版本、跟评测集绑定。每次改完跑一遍那五十条用例,通过率掉了就回滚。凭感觉调提示词是最低效的方式,因为你根本记不住上一次改的是好是坏。

第四条,给用户一个“看得见进度”的出口。Agent 跑起来动辄二三十秒,中间什么都不显示,用户会以为卡死了然后刷新,于是又来一次,账单翻倍。我的做法是每完成一步就往一个流式通道里推一条简短的状态描述,比如“正在读取数据源”“已获取 3 条记录,正在整理”。这条流跟模型输出是分开的,由 Harness 直接产生,不依赖模型的配合。这个小改动对体感提升极大,成本几乎为零。

第五条,预算要能随时掐,而且要掐得干净。我见过一个场景,用户点了取消,前端不显示了,但后端那个循环还在跑,因为取消信号只到了 UI 层,没传到 Harness。修法很简单:给RunState加一个cancelled标志,在循环体每次迭代开头检查一次。这个检查点必须放在循环开头而不是末尾,否则取消之后还要多跑完一整轮才停。这类“看起来不重要的收尾工作”,恰恰是 Harness 该干的活。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询