先说结论:这次把一个 agent 评测项目的名次从 top30 拉进 top5,我没有换基础模型,只动了 LangChain 的 Harness 层。所谓 Harness,指的是模型之外的整条 agent 运行骨架——提示词编排、工具注册与调用、记忆窗口管理、错误重试、输出校验这些环节的集合。很长一段时间里,优化 agent 的第一反应都是“换更强的模型”,但这轮实测下来,top30 到 top5 的差距,绝大多数不在“模型会不会”,而在“框架顺不顺”。
我负责的这个 agent 在榜单上一直稳定在 28-35 名之间,分数卡在 78 上下。换模型这件事不是没想过,但评估后有三个现实问题:成本高、迁移周期长、旧评测结果需要全部重跑。于是我把方向转向了 Harness——用 LangChain 重写编排层,让同一个底座模型跑出完全不同的效果。三周时间、三轮迭代,总分从 78.2 拉到 85.7,排名最终到第 4。这篇文章把完整复盘写下来,包括为什么瓶颈在 Harness、我具体改了什么、踩过哪些坑。
1. 为什么瓶颈在 Harness 而不是模型侧
1.1 看排行榜数据:卡在 top30 的不是“智商”不够
拿到项目后,我第一步没有改任何代码,而是把评测集的失败结果按任务类型做了分类统计。评测集有数百条任务,覆盖五类场景:多步工具调用、长文本检索、数学计算、表格处理、异常输入。统计结果很有意思,不是所有任务都差,而是集中“偏科”。
| 任务类型 | 基线成功率 | 主要失败点 |
|---|---|---|
| 多步工具调用 | 61% | 第一步正确,第二步开始工具参数乱填 |
| 长文本检索 | 72% | 检索结果冗余,模型抓不住关键信息 |
| 数学计算 | 78% | 偶尔算错,但整体可用 |
| 表格处理 | 66% | 表头理解错误,行列选择偏差 |
| 异常输入 | 54% | 直接放弃或陷入自我纠正循环 |
这个分布说明一个关键问题:如果模型真的“不行”,理论上五类任务应该全面崩盘。但现在的数据是,基础能力都还在,只是到了“多步”“多工具”“长上下文”这些依赖编排的场景就明显下滑。换句话说,评分卡的短板不是模型智力,而是 agent 框架对这些场景的支持太弱。
1.2 翻失败 case:模型被 Harness“捂住了嘴”
我抽了三十条失败样本,一条一条喂给同样的模型人工复测。复测方式是:把完整任务拆成小步骤,每步单独问一次模型。结果出乎意料,三十条样本里,有 23 条在拆步后都能正确答对。“模型会做,但在完整 agent 流程里做不对”——这是那段时间给我冲击最大的一句话。
以一条多步工具调用任务为例。原始流程里,模型第一步能正确调用查询工具获取数据,但到了第二步需要“基于第一步结果做筛选和排序”时,它突然把工具名记错,参数里出现一个根本不存在的时间格式。人工拆步之后,同样是这个模型,面对清晰的子任务描述,两步都能做对。这说明模型并不缺推理能力,缺的是 Harness 没有给它足够的结构支撑——没有任务清单、没有中间状态、没有错误之后的重试入口。
打个比方:一个人不是不会算账,而是你丢给他三十张报表却不给清单,他做到第五行就开始乱。换掉这个人解决不了问题,给他一张清晰的清单才是关键。
1.3 不换模型的现实理由:成本与回归成本
有人会问,为什么不干脆换更强的基础模型?我承认,换个新模型在最理想的情况下可能直接带来 3-5 分提升,但这笔账要算清楚。
第一是成本。强模型的 API 调用价格通常是当前模型的数倍,而评测集有几百条任务,每天回归测试要跑很多轮,一个月下来费用差距非常大。第二是迁移成本。模型一换,提示词风格、工具调用能力、输出格式都会变化,原来调好的 prompt 可能全部失效。第三是回归成本。旧模型的评测结果已经成了团队心里的“基线”,换模型意味着所有历史结论作废,必须重新验证。
Harness 改造则完全不一样。变化集中在应用层,底层模型不变,评测集可以随时回归,改动可以小步快跑。所以我的判断是:优化顺序应该先应用层、后模型层,先用最低成本把 Harness 里的水挤干,再考虑模型升级。
2. LangChain Harness 重构的核心思路与模块拆解
2.1 用 Runnable 把任务拆成“规划—执行—校验”三段
原版 agent 是一个巨大的 AgentExecutor,所有逻辑挤在一个循环里:模型想一步、走一步,错了就重新想,直到耗尽最大迭代次数。这种结构的最大问题是失败不可定位——你只知道结果错了,不知道是规划错、执行错还是校验错。
我用 LangChain Runnable 把流程拆成了三段:规划器负责理解任务并生成执行计划,执行器负责按计划调用工具,校验器负责检查执行结果是否符合要求。三者用 LangChain Expression Language 串起来。
from langchain_core.runnables import RunnablePassthrough, RunnableLambda def plan_step(state): plan = planner_chain.invoke({"task": state["task"]}) return {**state, "plan": plan, "current_step": plan.steps[0] if plan.steps else None} def execute_step(state): result = executor_chain.invoke({"step": state["current_step"]}) return {**state, "last_result": result} def validate_step(state): check = validator_chain.invoke({"result": state["last_result"]}) if check.passed: if len(state["plan"].steps) > 1: return {**state, "current_step": state["plan"].steps[1], "prev_result": result} return {**state, "finished": True} return {**state, "need_replan": True} agent_pipeline = ( RunnablePassthrough.assign(plan=plan_step) | execute_step | validate_step )拆完以后,最明显的变化是“每一步都可以单独观测”。某个环节出错,日志里能直接看到是哪一段产生的。排障时间从小时级降到分钟级,这是后续所有优化的前提。要特别注意,Runnable 的输入输出一定要用 state 字典显式传递,别隐式依赖全局变量,否则后面很难做并发和失败重放。
2.2 工具层才是真正的“模型外脑”
评测失败 case 里很大一部分是工具调用错误:参数名不对、类型不对、该用 A 工具却调了 B。这些问题我一度以为是模型笨,后来才发现是工具描述写得不够“像人话”。
LangChain 的 BaseTool 允许你定义 name、description、args_schema。description 是模型用来判断“什么时候该用这个工具”的唯一依据,但早期我们只写了一句话。举个真实例子:有一个日期处理工具,原始描述写的是“Handle date conversion”,模型经常在不需要转换日期的场景也去调用它。改成完整描述之后,误调率明显下降。
from langchain.tools import BaseTool from pydantic import BaseModel, Field class DateConvertSchema(BaseModel): date_str: str = Field(description="原始日期字符串,例如 2024-03-05") target_format: str = Field(description="目标格式,例如 YYYY/MM/DD") class DateConvertTool(BaseTool): name: str = "date_convert" description: str = ( "仅当输入包含明确日期且用户要求转换为其他格式时使用。" "如果日期已经是目标格式,不要调用。" "参数 date_str 必须包含完整年月日,缺少年份的日期拒绝处理。" ) args_schema: type[BaseModel] = DateConvertSchema def _run(self, date_str: str, target_format: str) -> str: # 实际转换逻辑 return converted这里有个经验:工具描述要包含三部分——适合做什么、不适合做什么、参数边界条件。很多模型误调用不是因为不会用工具,而是因为“不知道什么情况下不该用”。另外,args_schema 里每个字段的 description 也要写清楚,模型看参数说明生成 JSON 的成功率会大幅提升。
2.3 记忆与上下文:不要让模型读一本流水账
第二版 Harness 里,我们把所有历史消息全部塞进上下文。短任务没问题,但一旦任务超过五轮,模型就开始“失忆”——它记得上一句,忘了最开始的任务目标。而 if we 直接截断窗口,又会丢失关键中间结果。
最后采用了一套组合策略:滑动窗口 + 摘要 + 关键状态保留。具体做法是:对话超出 8 轮后,把最早的部分压缩成一段摘要,但中间结果里的工具返回值、关键数字、结构化数据单独存到 state 里,不进对话历史。
def compact_messages(state, max_rounds=8): if len(state["messages"]) > max_rounds * 2: early = state["messages"][:-max_rounds * 2] state["summary"] = summarizer_chain.invoke( {"history": early, "task": state["task"]} ) state["messages"] = state["messages"][-max_rounds * 2:] return state这个设计背后的逻辑是:对话历史负责“叙事”,state 负责“事实”。模型不需要反复看到几十行中间过程,它只需要知道“当前目标是什么、目前拿到了哪些关键数据”。压缩之后,上下文从最长一万多 token 压到平均三千 token 左右,长文本检索类的任务成功率从 72% 涨到 86%。
2.4 自愈:给 Agent 一个“犯错后怎么办”的通道
早期版本的 agent 只要工具调用失败,就原样重试同一句话。模型只会把相同请求再发一次,结果当然还是失败。这种“重试”本质上是碰运气,没有任何信息增益。
改造以后,我把重试分成三层:第一层是参数修正,工具返回错误信息时,把错误描述回传给模型,要求它只修正参数并重新调用;第二层是方案切换,如果同一工具连续失败两次,调整执行策略,比如改用另一种工具绕过去;第三层是任务降级,如果所有路径都失败,允许 agent 返回部分结果并明确标注“未完成项”,而不是硬造一个答案。
同时设置了“动作指纹”来检测死循环。把每次调用的工具名和参数序列化成一个短哈希,如果同一个哈希连续出现三次,强制终止并进入兜底逻辑。
def should_stop(state, max_same_action=3): recent = [act["fingerprint"] for act in state["actions"][-max_same_action:]] return len(set(recent)) == 1这个机制直接解决了异常输入类任务“反复自我纠正”导致的超时问题。异常输入类成功率从 54% 升到 71%,虽然后续还有提升空间,但至少它不再空转。
3. 实操落地:从 baseline 到 top5 的三轮迭代
3.1 基线复现:先把排名稳定在 top30
任何优化都从复现开始。我先把原版代码跑通,锁定了基线版本号,记录了当时的配置:模型版本、温度、最大迭代次数、上下文窗口长度、工具列表。然后跑完整评测集,拿到了 78.2 分、排名 31 的基线结果。
这一步容易被人跳过,但强烈建议不要跳。没有稳定基线,后续每个改动带来的收益就无法量化。我在项目里还做了一个小动作:把评测集按任务类型打散成十份子集,每次修改后优先跑其中两类,快速判断改动方向对不对,再跑全量。
基线的另一个作用,是建立“回滚保护”。任何一次改动如果让总分下降超过 0.5 分,立即回滚到上一版配置。实践下来,三轮迭代里我至少回滚了两次,其中一次是因为摘要压缩策略在表格处理场景里误删了关键表头信息。
3.2 第一轮:提示词与任务拆解优化
第一轮改动集中在提示词编排层。原版 system prompt 只有一句“你是 AI 助手,请完成任务”。我改成了一段结构化指令,明确要求模型先输出三步计划、计划每步只做一件事、遇到不确定时说明原因而不是猜测。
具体而言,我给规划器写了一个模板:
你是一个任务规划器。请将以下任务拆解为不超过 4 个步骤。 规则: 1. 每个步骤只能调用一个工具。 2. 如果上一步的结果是下一步的输入,必须在步骤描述中注明依赖关系。 3. 如果任务本身无法完成,输出 FAILED,不要强行规划。这个改动看起来轻,效果却很直接。模型开始按步骤执行,而不是把所有逻辑塞进一次工具调用。多步工具调用类任务的成功率从 61% 升到 75%。全量评测分数来到 81.6,排名升到约 17。
经验小结:模型带上了计划能力之后,最忌讳的是计划和执行混在一起。如果允许模型“边想边动”,它很容易在步骤间跳来跳去。让规划器只输出计划,执行器只看当前步骤,错误明显减少。
3.3 第二轮:工具路由与调用参数改造
第一轮之后,主要失分点变成了“工具选错”和“参数格式错”。我做了两件事:工具瘦身和路由分流。
工具瘦身是指原来几个大而全的工具拆成多个专用工具。比如原来的“数据处理器”同时负责读取、清洗、排序,模型经常搞不清该在哪个参数里传什么。拆成 read_data、clean_data、sort_data 三个工具后,每个工具的描述和参数模式都更清晰,模型选择的准确率上去了。
路由分流则是在多个强相关工具之间加了一个小路由节点。这个节点本身是一个极轻量的分类器,用 prompt 判断当前任务属于哪个子域,然后只把该子域的工具暴露给模型,大大减少了模型在十几把工具里“挑花眼”的概率。
代码层面是接入了一个内部 AgentRouter:
def route_tools(task_desc: str) -> list[BaseTool]: category = router_chain.invoke({"task": task_desc}) if category == "date": return [date_convert_tool, date_diff_tool, date_valid_tool] elif category == "math": return [calc_step_tool, equation_tool] # ...这一轮全量评测分数来到 83.2,排名升到第 8 名。值得注意的是,长文本检索在这个阶段提升最大,原因不是检索工具被改,而是上下文压缩让模型不再被无关信息干扰,能够更准确地定位答案。
3.4 第三轮:自愈与回归体系,冲进 top5
第三轮的重点是容错和评测回归。我给 agent 添加了上面提到的错误分类重试机制,同时搭建了一套自动化回归脚本:每次改动后,自动跑全部评测任务,输出每个任务类型的分数和失败摘要。
这个阶段有个反直觉的现象:我在异常输入类任务上加了重试策略,结果数学计算类任务分数反而掉了 1.2 分。原因是重试模块在数学任务中误判“计算过程不够精确”为失败,触发不必要的重跑,浪费了时间且引入了新的随机性。我没有直接去掉重试,而是加了触发条件:只有工具调用本身报错或者返回内容缺失时才触发重试,对“答对了但格式不够好”的情况直接通过。
这轮结束后,全量评测分数来到 85.7,排名升到第 4。最终表格如下:
| 轮次 | 核心改动 | 全量分数 | 排名 |
|---|---|---|---|
| 基线 | 原版 AgentExecutor | 78.2 | 31 |
| 第一轮 | 提示词与规划器 | 81.6 | 17 |
| 第二轮 | 工具拆分与路由 | 83.2 | 8 |
| 第三轮 | 自愈重试与回归体系 | 85.7 | 4 |
排名从 top30 到 top5,模型始终没换。最终 Harness 的形态是:规划器负责拆解,执行器负责单步工具调用,校验器负责检查结果,重试器负责异常恢复,外部套一个状态管理模块。LangChain 的 Runnable 组合让它整体依然是一个可调用的 chain,部署方式和原来几乎一样。
4. 常见问题排查与避坑记录
4.1 幻觉式工具调用,怎么按证据排查
模型会调用一个不存在的工具名,或者填一个完全不存在的参数,这类问题我遇见过很多次。首先要看的不是 prompt,而是这个工具在多久前一次成功调用时的上下文长什么样。很多情况下,模型不是“幻觉”,它只是在几十轮的混乱上下文里找到了一个旧字段名复制了过来。
解决这类问题的标准流程是:第一步,在工具函数入口加日志,记录每次调用收到的完整参数;第二步,用 trace 回放该任务的完整消息序列;第三步,找出错误参数最早出现的位置,通常是一次较早的工具返回结果里包含该字段。我遇到过一个案例,错误参数来自两条不同任务被拼接后残留的 JSON 片段,修正方法是在任务开始时清空上一轮的全部工具输出缓存。
4.2 千层饼式重试:死循环的三种解法
加了重试机制后,新的坑是“重试套重试”。模型在一条错误路径上反复横跳,每次失败生成新错误,于是 agent 永远在重试。我的解法有两个:一是动作指纹哈希,这个前面已经提到,同一动作连续出现三次立即终止;二是最大重试次数设置,单步工具调用最多重试两次,整个任务最多重规划一次。
死循环更隐蔽的表现是“每次参数都不同”。这种情况下哈希指纹检测不到,因为三次调用的参数不一样。后来我改用语义相似度判断,把工具名和错误信息拼成一句话,做向量相似度对比,相似度超过阈值的也视为循环。这一步让异常输入任务的成功率又涨了两个点。
4.3 评测收益的“虚高”陷阱:过拟合排行榜
盲目对着评测集错误 case 打补丁,分数会涨,但很可能只对评测集有用。我有一次针对表格处理任务的某个边界 case 加了特殊规则,全量分数涨了 0.3 分,但把评测集换成留出样本后,分数反而降了 0.4。这说明“补丁式优化”造成了过拟合。
我的做法是建立“评测集”和“留出集”双轨制。每次改动必须先跑评测集看收益,再跑留出集看泛化能力。如果留出集降幅超过评测集涨幅的一半,这个改动就不要合入。这个方法会拖慢迭代速度,但能保证排名是真实能力而不是记忆样例。
4.4 其他落地建议:结构化日志、小步实验、成本记录
整个项目最值钱的资产,不是那套最终 Harness,而是结构化日志。每一步的输入输出、工具调用、token 消耗、耗时全部落盘,这样每一轮迭代都能回放对比,找问题就像看回放录像。没有这套日志,很多失败 case 根本无从查起。
另一个建议是保持小步实验。每次只改一个变量,要么改提示词,要么改路由,要么改重试策略,不要同时动三个地方。这轮我踩过最深的坑就是第一轮同时改了提示词和摘要压缩策略,导致分数涨了但说不清到底是哪个改动起了作用。后来回退一半改动重新实验,多花了两天时间。
最后,每次迭代务必记录成本。Harness 优化带来的延迟和 token 消耗变化同样重要,一个排名高了 10 名但成本翻三倍的 agent,在实际业务里是不可接受的。
5. 最后再分享一点我自己的经验教训
这次项目最让我意外的,是“不换模型改 Harness”的收益竟然能这么大。我以前总把 agent 能力约等于模型能力,这次实践让我彻底改变思路:模型是发动机,Harness 是传动系统。发动机马力再大,传动系统卡顿,车速照样上不去。
如果让我对类似项目提一条建议,就是先花时间把失败 case 做成分类表,别急着写代码。数据会告诉你瓶颈在规划、执行、工具还是记忆,而不是让你猜测。另外,保持每一次改动可回滚,比追求一次到位重要得多。
最后再说个小技巧:每次评测跑完,除了看总分,一定要看“分类型分数变化矩阵”。有时总分涨了,某个子类狂跌,这种风险用总分是看不出来的。矩阵能让你快速定位改动伤到了哪里,必要时立刻回滚。这套方法适用于任何 agent 项目,和用不用 LangChain 关系不大。