前阵子我把一套基于 LangChain 的 agent 系统做了一轮集中改造模型一个没换prompt 也没有重写太多真正动刀的地方全部集中在工具定义、输出解析、决策循环和上下文管理这些“管线外壳”上。结果在某个公开 agent 评测集上排名从 top30 爬到了 top5。这个成绩放到整个榜单里不算炸裂但它给我的启发比分数本身更大很多 agent 任务做不好根本不是模型不会而是模型外面那层 harness 没给它一个靠谱的交付环境。所谓 harness就是模型本体之外的所有编排代码系统提示词怎么搭骨架、工具怎么把能力描述给模型、模型输出怎么解析、出错分支怎么捞回来、历史上下文怎么裁剪。它不是模型本身却决定了模型能不能端到端稳定完成任务。这篇文章不写模型选型也不空谈“Agent 设计模式”就写这次复盘的完整过程包括诊断方法、六个重点改造环节、前后数据对比以及后来踩过的真实坑。1. 先说结论决定 agent 排名的往往不是模型1.1 什么是 harness为什么它被低估很多人听到 agent 性能差第一反应是“换更强的模型”。但实测过几轮评测集后你会发现同一个模型用两套不同的 harness 去接成绩可能差出一大截。我把 harness 理解为“模型底座之外的所有工程层”工具定义是模型的操作手册系统提示词是任务交接文档输出解析是把模型口语翻译成程序指令决策循环是敏捷开发中的复盘机制上下文管理是会议纪要。这些都不改变模型权重却能直接决定模型的行为空间有多大、容错能力有多强。一个很形象的例子把模型比作新来的高智商实习生harness 就是公司的流程、工具和汇报模板。实习生再聪明如果入职第一天没人告诉他“报销走什么流程”“周报用什么格式”他也会交出一堆漂亮但无法被业务直接使用的东西。排行榜上的 agent 大部分死于这类问题而不是“智力不足”。1.2 我们的基线诊断失败都藏在装配线上开始改造之前我先做了一天的事故归因。把评测集跑完把 agent 的完整运行轨迹全部导出然后按失败模式拆开一条条打标签最后汇总成一张表失败类型占比约典型表现输出格式不可用37%模型生成了多余解释JSON 解析失败动作无法执行工具选错或参数填错25%有精确工具不用偏去调模糊的搜索工具中间步骤死循环15%同一个动作重复执行到超时关键指令/上下文被挤丢13%工具返回结果过长把原始任务说明顶出窗口最终答案不符合评测口径10%答案语义对但缺字段、格式不匹配得零分这张表最有价值的不是比例而是“模型能力问题只占一小部分”。37% 的输出格式问题本质是解析层太脆弱25% 的工具选择问题本质是工具描述写得像天书15% 的死循环本质是循环控制里缺少终止条件。所有这些都不需要换模型只需要把 harness 做得更稳。1.3 一句话结论先修 harness再考虑换模型。除非你真的确认模型对任务完全不理解否则优先把注意力放在工具表达、输出解析、上下文和评测对齐上。这次的排名跃升基本就是这四个方向的组合收益。2. Harness 改造的六个关键环节2.1 工具描述从“说明书”改成“教学手册”工具描述是最容易忽略却收益最高的改动。LangChain 的tool装饰器或者StructuredTool.from_function都能把函数签名自动转成模型可读的 schema但 schema 里通常只有参数名、类型和一行描述。这种“说明书”式描述对一个没有业务上下文的大模型来说信息量远远不够。改造前我的工具描述长这样def query_files_by_keyword(keyword: str, date_range: str ) - str: 搜索文件模型只知道“有个搜索文件的功能”但完全不清楚到底搜哪些目录关键词怎么匹配和另一个get_file_by_name工具有什么区别于是它总是选错工具或者参数填得很随意。改造后我把描述当“教学手册”来写from langchain.tools import StructuredTool def query_files_by_keyword(keyword: str, date_range: str ) - str: 检索用户授权工作目录下的文档返回路径与摘要。 ... query_files_by_keyword_tool StructuredTool.from_function( funcquery_files_by_keyword, namequery_files_by_keyword, description( 仅用于按关键词检索『用户授权目录』中的文档。 当用户已经给出精确文件名时不要用本工具请改用 get_file_by_name。 若用户询问的是聊天记录、邮件或数据库内容不要调用本工具。 返回结果超过5000字符时会自动截断截断后可用 get_file_detail 获取局部内容。 ), )注意我加了三条关键信息适用边界、和其他工具的区分、返回结果的截断策略。这些内容不需要模型额外学过只需要描述里讲清楚它就能在决策时少踩很多坑。实际做的时候我把所有工具都过了一遍凡是描述里出现“优化前那种一句话简介”的全部重写。规则很朴素如果这个工具是一个新同事入职我希望他看到的说明是什么样的就写成什么样。2.2 Prompt 骨架把模型当成新来的执行者系统提示词不需要花哨但一定要有稳定的骨架。我的最终模板是下面的结构顺序固定每一段都有明确职责你是负责完成特定任务的执行者。 任务背景xxxx 可用资源以下工具可以帮助你完成任务按需调用。 执行原则 1. 先理解任务目标再拆解步骤。 2. 每一步只调用一个工具拿到结果后再决定下一步。 3. 如果某个工具连续两次返回错误不要立刻重试先换思路。 4. 最终答案必须包含 xxx 字段禁止输出额外说明文字。这个骨架看起来简单但有几个细节值得展开首先是“任务背景”段落。很多 agent 只把用户问题丢给模型缺少任务发生的环境信息。比如评测任务里要求“把结果保存到指定路径”如果背景里不写明路径规则模型就不知道到底该存哪里。背景越具体模型越少自由发挥。其次是“执行原则”。原则不需要多但必须是可执行的约束而不是“请小心”“请仔细思考”这种口号。我写的第四条“禁止输出额外说明文字”就解决了至少 10% 的格式失败问题。模型是概率生成器你不给它硬约束它就会觉得加两句礼貌用语也没关系。最后是“把模型当执行者”这个定位。不要让它觉得自己是全能助手它可以调用工具但必须按照既定协议输出。这个定位转变对行为稳定性影响很大。2.3 输出解析先容忍脏 JSON再谈严格模式评测任务里模型不一定都会输出标准 JSON。有时候多行解释有时候用 markdown 代码块包着 JSON有时候字符串里混了非法转义。原来的解析器是json.loads一把梭失败就抛异常agent 直接中断。后来我把解析流程改成了“清洁 - 提取 - 兜底”三层结构import json import re def repair_json(text: str) - dict | None: if not text: return None candidates [text] # 去掉 markdown 代码块 if text.startswith(): candidates.append(re.sub(r^(?:json)?\s*|\s*$, , text, flagsre.S)) # 直接截取最外层花括号内的内容 start, end text.find({), text.rfind(}) if start 0 and end start: candidates.append(text[start:end 1]) for c in candidates: try: return json.loads(c) except json.JSONDecodeError: continue # 最后兜底匹配少量常见字段 m re.search(raction\s*:\s*([^]), text) n re.search(raction_input\s*:\s*(.*), text, re.S) if m and n: return {action: m.group(1), action_input: n.group(1).strip()} return None然后在 agent 循环里这样处理parsed repair_json(raw_action) if parsed is None: # 不要把原始错误直接抛给用户而是把“解析失败”作为一个信号反馈给模型 return {action: __parse_error__, action_input: raw_action}这么改之后解析成功率从约 61% 提升到约 93%。这里要说明一下不是说所有模型都需要这样造轮子如果你的模型服务商有 strict JSON mode 且稳定可以优先用平台能力。但很多开源底座模型或者自部署模型没有这个特性一个容错解析器就是刚需。提示解析失败时不要直接抛异常。把原始输出作为 parse_error 反馈给模型比让它从头重新思考更有效。模型看到自己的“脏输出”被退回往往下一轮就能改正。2.4 决策循环错误要能“流”回模型默认的 AgentExecutor 循环是“模型输出动作 - 执行工具 - 结果拼回 scratchpad - 模型继续”这个循环本身没问题缺的是两层防护。第一层防护是“解析错误回灌”。如上所述解析失败不是终结而是一个工具执行结果塞回给模型。模型能从中学习到“我刚才的输出格式不对”从而在下一轮修正。第二层防护是“重复动作检测”。我在循环里维护一个最近几步的动作指纹如果发现同一个(tool_name, input)连续出现两次以上就打断重试并给模型注入一条提示“你刚才已经执行过相同操作结果没有帮助请换一种方式。”这一条直接干掉了大部分死循环问题。还有一个容易被忽略的点不要每步都让模型“反思”。有些教程喜欢让 agent 每走一步都总结一次“我现在进展如何”听起来很 smart但实际会大幅增加 token 消耗而且反思多了模型反而容易把简单问题复杂化。只有在失败发生时才启动反思分支这才是合理的成本控制。2.5 上下文装配别让历史记录吃窗口评测任务里经常出现“工具返回几百 KB 内容”的情况。如果不加控制跑几步之后上下文窗口就被工具结果塞满原始任务说明被挤掉模型开始“失忆”。我的处理原则是分成三层第一层原始任务说明永远保留不参与裁剪。这是 agent 的北极星丢了就全乱了。 第二层最近的 3~5 步完整保留因为模型决策需要基于最新工具输出。 第三层更早的中间结果压缩成摘要。每完成一步就把上一步的工具输入和输出压缩成一句话作为历史记录保留。实现上并不复杂本质就是自己维护一个 messages 数组而不是把所有内容无脑追加。LangChain 的format_to_openai_functions只负责格式化 scratchpad真正的裁剪逻辑要自己写。我写了一个简单的trim_scratchpad函数保留最近 N 步剩下的拼成一个“历史摘要”字符串实测能有效把单任务 token 消耗压低约 20%。2.6 评测对齐先建自己的小样本集排行榜分数不完全是“能力”很多是“对齐”。评测集的评分规则千奇百怪有的要求最终答案必须严格匹配 JSON 字段有的要求可执行文件必须路径正确有的只看最终输出文本。你在 harness 里加一个“最终答案校验器”专门负责把模型产出改造成评测要求的格式分数就能立刻涨。具体做法是先从公开评测集里挑 30 个有代表性的任务组成私有验证集每次改动后跑一遍看分数变化。这比每次直接提交官方评测要快得多也能防止单次随机波动带来的误判。私有验证集还有一个作用防过拟合。公开评测集的题目是固定的太针对性去修某几道题会分数虚高但泛化能力下降。我会把 30 题拆成两组15 题在迭代时频繁使用另外 15 题只在每周五最后验一次防止自己被 leaderboard 带偏。3. 实操过程与数据复盘3.1 改造前后的关键指标下面是这次改造前后我在私有验证集上记录的一组数据约数指标改造前改造后变化说明可用轨迹率51%82%运行过程中没有中断或死循环的比例工具误用率按步骤统计17%6%模型调用错误工具或填错参数的比例最终答案通过率43%71%输出被人工复核认定正确的比例平均执行步数7.611.2步数变多因为增加了校验和纠错环节单任务 token 消耗基准值约 15%代价是略微变慢但可接受最明显的是“可用轨迹率”从 51% 到 82%。“可用”意味着 agent 没有因为解析失败而中断、没有死循环、每一步的工具调用都是可执行的。这条线不涨后面的一切都是空中楼阁。代价也确实存在平均步数增加、token 消耗增加。原因不难理解校验和纠错本质上是多花几次模型调用换取稳定性。在排行榜这种看重成功率的场景这个 trade-off 非常划算。3.2 一个典型坏 case 的转化过程挑一个印象最深的例子任务要求“找出最近三个月项目周报中所有提到某新功能的条目按日期倒序整理成 CSV并保存到指定路径”。改造前 agent 的运行轨迹是这样的第一步调用list_dir(base_path)列出整个目录工具结果一万多字符。第二步没有按关键词搜索而是去逐个调用read_text(file)试图自己翻文档。中途因为某个文件是二进制读取失败agent 开始反复重试同一个文件。最终超时没有输出 CSV。这个 case 里模型不是不会做而是从一开始就选错了策略。原因藏在两个地方list_dir的描述没有告诉模型“这个操作成本高、结果可能很大”read_text也没有说明“只能处理文本文件遇到非文本请跳过”。改造后我重写了三个工具的描述还在系统提示词里加了一条执行原则“优先使用关键词搜索定位文件而不是遍历目录。”同样的模型同样的任务新的轨迹变成了调用query_files_by_keyword(新功能, date_range最近三个月)得到候选文件列表和摘要。对每个候选文件调用get_file_detail获取关键段落。把摘要整理成 CSV调用save_file保存。最终输出路径和确认信息。整个流程 4 步完成没有死循环没有非法格式。这类 case 在复盘里最有价值因为它证明了一个事实很多失败不是模型不懂任务而是 harness 给模型铺设的信息路径是错的。工具描述和系统提示词就是那条路径路径顺畅了模型自然能走通。3.3 可复用的 Harness 改造检查单如果你也想复现这个过程这是我自己整理的行动清单导出至少 20 条失败轨迹按失败类型打标签先看分布再动手。所有工具描述重写一遍补上“适用边界”“和其他工具的差异”“超长结果处理策略”。系统提示词固化成五个段落任务背景、可用资源、执行原则、终止条件、最终答案格式。输出解析改成“清洁 - 提取 - 兜底”三层解析失败要作为信号回灌。加重复动作检测连续相同操作最多重试一次第二次必须换路线。建立私有验证集15 题常规调参 15 题防过拟合每周跑一次 holdout。每次只改一个变量记录前后差异不要同时改工具描述和系统提示词。4. 高频踩坑与排查经验4.1 踩过的坑统一列给你改造过程中我踩的坑比预想多得多这里选几个最有共性的现象你以为的原因实际原因处理方式解析错误反复出现模型不会输出 JSON提示词里缺少“禁止输出额外文字”约束加硬性格式约束 解析失败回灌上下文越来越长任务跑一半就“忘”模型上下文窗口不够历史记录无脑追加原始任务被挤丢固定保留任务说明压缩中间步骤工具总是选错工具数量太多相似工具描述没有做区分每个工具描述补“什么时候不该用”改了 prompt所有任务分数都降改动了全局变量同时改了多个环节无法定位回归源每次只改一个变量保留基线数据本地验证集全过官方榜单大跌评测集有泄漏私有集跟公开集太像过拟合了每周换 heldout 集防止背题特别想展开讲的是“改了 prompt 分数反而跌”这个坑。我有一周贪快同时改了工具描述、系统提示词、解析器三块结果私有验证集从 70% 直接掉到 55%。排查了半天才发现新提示词里有一段“你可以尝试多种方法”的表述让模型变得特别喜欢乱试工具把之前工具描述的约束全抵消了。这个教训让我立了一个规矩任何改动先在单个 case 上把新旧行为差异跑出来确认没问题了再应用到全量验证。宁可慢一点也要保证每一次改动都能归因。4.2 调试时要盯住的三类日志调试 agent 比调试普通代码难因为“输出对不对”只是表象你要看的是“模型每一步到底收到了什么”。我自己调试时只盯三类日志第一类是原始输出。不要只存解析后的 action原始模型输出要完整保留。很多时候解析失败你得看看模型到底说的是什么是格式不对还是内容本身就错了。第二类是工具返回值。记录工具名、工具输入、返回结果的前几百字符。这能帮你定位是不是“工具结果质量差”导致模型后续决策失误。第三类是上下文快照。每轮结束后把当时发给模型的 messages 长度、裁剪后保留的内容导出一份。检查有没有“任务说明被顶出窗口”的情况。我在代码里加了很简单的 JSONL 日志每一轮写一行import json, time with open(trace.jsonl, a) as f: f.write(json.dumps({ ts: time.time(), step: step, action_raw: raw_action, action: parsed_action, tool_input: tool_input, tool_result_head: tool_result[:500], messages_char: sum(len(str(m.content)) for m in messages), parse_status: status, }, ensure_asciiFalse) \n)有了这个文件你可以把某一条失败轨迹完整回放出来看到底是哪一步开始跑偏的。比在终端里盯着verboseTrue的打印要清晰得多。4.3 几个提升手感的规矩做了这么多轮 agent 调优最后留三条“手感”经验第一不要修单个失败 case 就高兴。同一个 agent同样的问题可能有三种不同死法修完一个 case 要立刻跑同类的其他 case。我给自己定的标准是修复一个 case 后同 cluster 至少十个 case 都过才算真正修完。第二负向约束往往比正向引导更有效。“不要用 A 工具”“不要输出解释文字”这类否定句对模型行为的约束力比“你应该怎样怎样”强很多。第三排名提升之后回头重新看一次原始任务。评测集和你线上的真实任务永远有 gapharness 改到排行榜前几不代表你的 agent 在真实业务里无敌。把同样的工程规范迁移到线上场景用小流量验证才是值得投入的下一个方向。最后说点个人体会。改 harness 的收益比很多人想象得大但它也特别容易让人产生“模型没问题都是我 prompt 不行”的错觉。真正健康的做法是把一次任务失败先拆成模型、接口、解析、上下文、评测口径五层再决定动哪里。我这次最大的收获不是 top5 的排名而是建立了一套“先诊断、后归因、单变量修改、日志闭环”的调优流程。这个流程可以复制到任何 agent 项目里下次遇到奇怪的问题至少不会慌。
阅读完成 · 觉得有帮助?