人人在说 Agent,但多数人其实做的是“带工具的聊天机器人”。最近我把一个多智能体项目重构成可稳定交付的工程化方案,回头复盘时发现:很多弯路都源于对 Agent 本质的理解太潦草。今天想围绕Agent = LLM + Harness这个公式,聊聊我踩过的坑和梳理出来的经验——如果你正打算从零搭一个 Agent,或者已经被“智能体不可用”“输出不稳定”折磨得头疼,这篇应该能帮你省下不少时间。
1. 内容整体设计与思路拆解
1.1 为什么“LLM 很强但不够用”
先讲一个我的真实经历。最初搭建智能体时,我的想法很简单:调用一个大模型,把用户问题丢进去,配上几句 system prompt,期待它“发挥智能”。结果原型能跑,但一接真实业务就崩——模型忘记该调哪个工具,参数格式时而对时而错,多轮对话里上一轮的信息被覆盖,更别提让它自主规划多步任务。问题不在模型本身,而在于我根本没有给模型搭一个可以稳定工作的外部结构。
这就是Harness的意义。它相当于给模型配了一套骨架:感知环境、记忆状态、选择工具、迭代行动计划、控制退出条件。LLM 负责“思考”,Harness 负责“存在”。你可以在同一个 LLM 上换四种 Harness,得到四个能力差异巨大的 Agent;你也可能因为 Harness 设计太脆弱,白白浪费了一个强大的模型。
1.2 谁需要重新审视这个公式
如果你只做“单轮问答 + 一次性工具调用”,其实 Harness 的复杂度可以很低;但如果你要做多轮自主任务、需要在不同工具之间切换、需要管理长期上下文,甚至要让 Agent 在生产环境稳定运行,就必须把 Harness 当成核心架构来设计,而不是“顺手拼个循环”。
我自己把使用场景分成了三类:
- 轻量场景:即问即答,工具数量小于五个,对精确性要求不极端。
- 复杂场景:任务需要多步规划、过程中依赖前序结果、需要查表或检索。
- 生产场景:强约束、需要审计、结果可评估、异常可恢复。
你能清楚地看到,从轻量到生产,Harness 的分量和难度是急剧上升的。很多人卡在“原型能用”到“生产能用”的鸿沟,真正原因就是没有把 Harness 当作一等公民来设计。
2. 核心细节解析与 Harness 的五大构成
如果把 LLM 比作大脑,Harness 就是整个身体:它决定模型能感知什么、能操作什么、能记住什么、什么时候停手。下面是我目前拆分 Harness 时的五个核心模块,纯干货,直接可以对照着检查自己的实现。
2.1 循环与终止条件设计
Agent 和普通 API 调用最大的区别是循环。简单循环可以写成:
messages = [] while True: response = llm.chat(messages, tools=tool_schemas) messages.append(response) if response.tool_calls: for call in response.tool_calls: result = execute_tool(call) messages.append(tool_result_message(call, result)) else: return response.content这段代码看起来简单,但魔鬼在细节。终止条件绝不能只靠“模型不再调用工具”这一条。我在生产环境里至少设置了三层保险:
- 模型主动结束:输出 final_answer 或是停止调用工具。
- 最大迭代次数:防止模型陷入死循环或工具反复失败。
- 时间预算:如果整个循环超过预设时长,强制终止并返回当前最佳结果。
有一次测试,模型因为某个工具返回值格式变化,不断重新调用同一个工具,循环了 40 多次才被我的最大迭代数拦住。没有保险,这就可能成为线上事故。
2.2 工具调用协议与参数约束
工具接入是最容易写、也最容易出暗坑的地方。核心原则只有一个:传给模型的结构越简单越好。
我的做法是采用统一协议:
{ "tool_name": "query_order_status", "arguments": { "order_id": "A123456" } }每个工具向模型暴露一个名称、一句功能描述、参数的 JSON Schema。模型不感知具体 API 地址,不感知鉴权,这些都由 Harness 封装。好处非常明显:模型只需生成一个简单的 JSON 结构,复杂逻辑全部在工具执行层消化。
但参数校验必须做严。模型可能直接幻觉出不同的参数名,比如 schema 里是 order_no,它传成了 order_id。我在 Harness 里加入了自动纠错映射,把近义参数名对应起来;如果关键参数缺失或类型不合法,就直接返回错误结果给模型,让它自行修正,而不是抛异常中断整个流程。
2.3 上下文管理与记忆机制
这是 Harness 里最容易和“聊天记录”混淆的部分。聊天记录只是把消息原样堆叠,但 Agent 的上下文管理要解决两个根本问题:信息太多装不下,以及信息之间没有结构。
我习惯把上下文拆成几个独立槽位:
- 本轮对话消息流(用于模型推理)。
- 任务持久状态(比如用户目标、已完成步骤、当前待办)。
- 外部记忆(向量数据库检索出的相关片段,或长期事实)。
每次调用模型前,Harness 负责把这些槽位整理成一段有结构的 prompt。比如:
[当前任务] 用户需要查询订单A123456的物流状态,并根据状态决定是否发起补发。 [会话历史] 用户:这个订单怎么还没到? 助手:我帮您查询一下,请稍等。 [工具结果] 物流接口返回:订单状态为“运输中”,预计三天后送达。这样的结构让模型清楚地知道:哪些是约束,哪些是历史,哪些是刚获取的事实。很多 Agent 表现“智力下降”,其实不是模型不行,而是上下文里有用的信息和噪声混在一起,模型无法区分。
2.4 模型路由与多模型协同
单模型方案最省事,但不是所有任务都适合一个模型完成。一个务实经验是:不要把复杂推理、工具调用、内容生成全部压到同一个模型上。更可靠的做法是设计 Harness 内置模型路由——根据任务类型分发。
比如,工具调用和短期规划用快速模型,因为延迟低;复杂决策和代码生成用强推理模型;提炼总结可以用低成本模型,因为不需要高智力。模型路由的判断可以由 LLM 自己完成,也可以基于关键词规则或分类器。
路由逻辑要保证可解释、可观测。我的做法是在每条日志里记录路由依据,方便复盘。一次线上问题排查中,正是因为路由日志里记录了某条请求被低成本模型处理,才发现分类规则把“资金计算”误判成了“文本改写”,进而修正了规则。
2.5 观测性与人工介入点
生产环境里,Harness 第三个必不可少的部分是观测。你至少要记录每个循环的输入输出 Token 数、工具调用序列、每步耗时、最终是否成功。
这里我强烈建议你给 Agent 加一个“运行轨道”视图。类似:
调用历史: 1. 用户提问 → 模型选择 query_order_status 2. 工具返回运输中 → 模型选择 confirm_delivery_info 3. 工具返回预计时间 → 模型输出最终结果有了这个轨道,出问题时你才能迅速定位是模型决策错了、工具写错了,还是上下文丢了。没有观测数据的 Agent,在生产环境里就是盲盒。
人工介入点则是另一个被我早期忽略的设计。有些任务高风险,比如退款、删除数据,不要让 Agent 自作主张。Harness 里应设置审批节点:模型可以生成执行计划,但要真正调用敏感工具,需要等待人工确认。
3. 实操过程与核心环节实现
这部分我想给到一个可以直接“抄作业”的最小 Harness 实现,同时包含你实际动手时最容易踩坑的几个点。我将用一个简单的“订单客服 Agent”作为示例,完整走一遍从设计到落地的过程。
3.1 从零搭一个最小可用的 Agent Harness
我的最小 Harness 分四层,不搞任何花哨东西:
第一层是 Agent Core,负责主循环。
第二层是 Context Builder,负责把原始对话、工具结果、任务状态组装成结构化的上下文。
第三层是 Tool Executor,负责实际调用外部工具,统一认证、超时、错误处理。
第四层是 Policy Enforcer,负责权限控制。
在代码层面,核心主循环可以这样写:
class MinimalHarness: def __init__(self, llm, tools, max_iterations=10): self.llm = llm self.tools = {t.name: t for t in tools} self.max_iterations = max_iterations def run(self, user_query): state = { "query": user_query, "history": [], "tool_results": [], "steps": [], } context = ContextBuilder.build(state) for i in range(self.max_iterations): response = self.llm.chat(context, tool_schemas=self.tools_schemas()) state["history"].append(response) if response.is_final_answer: return response.content if response.tool_calls: for call in response.tool_calls: # 权限校验 outcome = PolicyEnforcer.check(call.name, call.arguments) if not outcome.allowed: return "操作被拒绝,需要人工审批。" result = self.tools[call.name].execute(call.arguments) state["tool_results"].append({"call": call, "result": result}) context = ContextBuilder.build(state) else: return response.content return f"已达到最大迭代次数 {self.max_iterations},请简化任务或补充信息。"这个骨架非常朴素,但它已经包含了一个能运转的 Agent 所需的全部要素。实际动手时,你在增强 ContextBuilder 的上下文组织和 PolicyEnforcer 的审批逻辑上花的时间,应该比在循环本身上的时间多得多。
3.2 工具接入的字段设计与避坑细节
工具接入方面我总结了一套字段设计模板,每个工具暴露给模型的 schema 只有四个核心字段:
| 字段 | 含义 | 示例 |
|---|---|---|
| name | 工具唯一标识 | query_order_status |
| description | 模型看到的用途说明 | 根据订单号查询物流状态和预计送达时间 |
| parameters | 参数 JSON Schema | order_id 字符串,必填 |
| required_permission | 调用所需权限级 | read_only / execute / sensitive |
description 写得好不好,直接影响模型选工具的准确率。我见过太多的 description 写“这是一个用于查询订单状态的函数接口”,模型连什么时候该用它都判断不了。更好的写法是带场景和时机:例如“当用户询问订单物流进度、快递更新时间、是否发货等情况时使用”。
参数 Schema 遵循一个铁律:能不用嵌套就不用嵌套,能用字符串就不用结构体。模型生成复杂 JSON 结构时,出错率呈指数上升。宁可工具内部做解析,也不要让模型去拼高度复杂的对象。
3.3 让 Agent 不自作主张:权限与审批链
PolicyEnforcer 是 Harness 里负责安全垫底的模块,设计原则是“默认拒绝,显式放行”。
我在实际项目中设置了三级权限:
- read_only(只读查询)
- action(执行变更操作)
- sensitive(退款、删除、外部通知等风险操作)
当 Agent 尝试调用敏感工具时,Harness 不会直接执行,而是生成一个“待审批动作卡片”,把模型打算执行的参数和理由发给人工。人工审批之后,后续循环才能继续。
这套设计看起来牺牲了一点“全自动”的爽感,但它在生产里的价值极大。用户的信任感,往往来自 Agent 在关键动作前“知道停下来问一句”。
3.4 我实测下来最稳的提示词组织方式
提示词内部结构,我经过多次调整后固定为四段式,并且多次验证它比“一大段自由描述”稳定得多:
- 角色与约束:用三句话定义 Agent 的身份、职责边界、不允许做的事。
- 可用工具清单:列出工具名、触发时机、关键参数,并说明工具不适用时的替代方案。
- 当前状态总览:包含已获取事实、未完成步骤、需要用户确认的歧义点。
- 输出格式要求:规定最终回复的样式、必须包含的要素、语气。
顺序也重要。先让模型明确边界,再看到工具,再知道当前状态,最后产出。如果顺序颠倒,模型很容易过早陷入细节或提前输出结论。
有一次我把工具清单放在最前面,结果模型在用户还没说完需求时就开始推断该调哪个工具,导致多轮追问逻辑混乱。调整顺序后,模型先专注理解任务,再在合适时机选工具,效果立竿见影。
4. 常见问题与排查技巧实录
4.1 工具选了但参数总是不对,怎么办
这是最普遍的问题。现象是:模型知道该用哪个工具,但生成的参数要么缺失、要么格式不对,甚至凭空造出 schema 里没有的字段。
我的排查顺序如下:
- 先确认 schema 是否足够简单。如果参数里有嵌套对象,尝试拍平。
- 再检查 description 是否说明了参数的获取来源。比如 order_id 可以从用户对话中提取,还是需要先调用列表接口拿 ID。
- 最后给 Harness 加一层容错归一化:解析失败时,把原文返回给模型,让它根据错误信息重新生成参数,而不是让异常直接中断流程。
容错归一化非常有效。现在我的错误率大部分能降到 0.5% 以下,剩下的极端情况靠人工兜底。
4.2 Agent 陷入循环调用,如何快速止损
死循环的经典表现:模型反复调用同一个工具,每次返回的结果都类似,但它依然不断重试。常见诱因有几种:
- 工具结果里包含的错误信息让模型误以为“还不够”。
- 最大迭代次数设置过大,给了模型太多试错空间。
- 上下文里缺少“已尝试过”的记录,模型不记得自己刚刚做过同样的事。
我的止损方案是在状态里维护一个“已尝试步骤列表”,把它和工具结果一并写入上下文。模型能看到自己的历史,就不太会重复劳动。同时配合最大迭代数兜底,超过即强制收敛并触发人工或者备选方案。
| 排查点 | 检查方式 | 建议调整 |
|---|---|---|
| 上下文是否包含最近工具调用 | 查看日志中的调用序列 | 把历史调用反馈给模型 |
| 工具结果是否提供了足够信息 | 手动模拟一次完整调用 | 丰富工具返回错误描述 |
| 最大迭代是否过大 | 统计正常任务平均步数 | 设置为 2~3 倍平均值 |
4.3 上下文太长导致模型“失忆”,如何取舍
Agent 跑久了,上下文一定膨胀。最笨的做法是暴力截断,但会丢失关键信息。我的做法是分层压缩:
- 把详细工具结果压缩成摘要信息,只保留关键字段。
- 把早期对话轮次摘要化,比如“用户已确认收货地址”。
- 长期记忆落库到向量库,只在需要时检索回填。
压缩之后,模型能始终专注于当前最重要的信息,而不是在一堆历史噪声里翻找。效果非常明显,尤其当任务链长、工具调用多时,压缩机制能显著提升后续步骤的准确性。
4.4 模型输出“像人话”但与事实不符,怎么治
这是最伤透脑筋的一类问题。模型回答流畅、自然、自信,但关键数据是错的。排查思路分三条路:
- 确认工具结果是否正确注入上下文。有时工具执行成功,但结果没有被正确拼进 prompt。
- 确认模型是否被要求“基于工具结果作答”。如果没有明确约束,模型会调用自己的知识库补全想象。
- 确认终止条件是否过早触发。若模型在还没完成全部工具调用时就输出了最终答案,也会造成事实缺失。
我的治本方案是在系统约束里写死一句:“所有涉及数据、状态、价格、时间的内容,必须以最近一次工具返回值作为唯一依据。若工具未返回对应信息,应明确告知用户暂时无法查询,而非推测。”
4.5 六个真实项目中反复出现的高频雷区
最后列一份我用血泪换来的雷区清单,很多都是文档里不会写的内容:
- 工具函数内部隐藏了重试逻辑:导致外部 Harness 以为调用成功,但实际耗时已经超标。
- 工具返回的文本过长:塞进上下文直接挤爆了有效 token,模型注意力急剧下降。
- 多个工具返回结果互相矛盾:模型没有合理仲裁机制,导致它随机采信一个。
- 模型在最终回复里重复列出所有中间过程:要明确约束输出内容只包含最终结果。
- 测试环境和生产环境的工具返回格式不一致:换到生产环境就崩,排查半天发现是不同团队维护的接口字段风格不一样。
- Harness 自身缺少定期清理无状态任务的能力:长时间线上运行后,内存里堆积大量半途中断的任务上下文,导致性能下降甚至 OOM。
5. 写在最后的个人体会
这套Agent = LLM + Harness的框架,我用了将近一年。刚接触时总觉得“Harness”只是个好看的词,多做几个项目后发现,真正决定一个 Agent 能不能交付的,恰恰就是 Harness 里那些不起眼的循环控制、上下文组织、权限管控和观测埋点。模型更像是一个能力源,而 Harness 决定了这个能力源能不能被安全、稳定地释放出来。
我个人最深的体会是:如果你还在“调 prompt 试图让模型表现得更聪明”,不如把精力分一半到“把 Harness 的骨架搭得再稳一点”。前者是给车加更好的汽油,后者是修好底盘和转向系统。一辆底盘松垮的车就算装上顶级引擎,也跑不了高速。
最后再分享一个小技巧:每次迭代 Harness 的时候,都给自己的系统加一个“故障注入测试”——故意让某个工具超时、返回错误格式、连续报错,看 Agent 能不能优雅处理。这个习惯帮我提前发现了大量生产环境才会爆炸的隐患,强烈建议尝试。