1. 先把边界划清楚:Agent、Harness 和组件化到底什么关系
1.1 agent 与 harness 其实是什么关系
最近被一个做 RPA 的朋友问懵了:"harness 到底是啥?跟 agent 有啥区别?" 我解释了半天,最管用的不是定义,而是一个类比——把 Agent Harness 想成一架飞机的座舱。顺着这个类比往下走,我居然一口气推导出了 12 个组件,团队的架构评审一次通过。这篇就把这套思路完整写出来:先划清 agent 和 harness 的边界,再从座舱类比推导 12 个组件,最后给出一份可以直接抄作业的最小实现和排障清单。适合正在搭 Agent 框架的开发者,也适合被各种概念绕晕、想搞懂 Agent 内部构造的产品和技术负责人。
一句话版本:模型是引擎,Agent 是驾驶员,Harness 是座舱。Agent 负责"下一步该做什么"这类决策,Harness 负责让决策能安全、可靠、可追踪地落地——上下文怎么拼、工具怎么调、状态怎么记、出错怎么恢复、过程怎么审计。很多人以为 agent 就是"一个 while 循环 + 调模型",其实那只是最朴素的 harness:一个 ReAct 循环里的 observation、thought、action、result,本质就是上下文管理、规划、工具调用、状态回写这四个组件的雏形。
好多人分不清"harness 跟 agent 的区别",是因为开源项目里这两个词经常混着写。我自己的判断标准很简单:如果一段代码在直接决定"做什么",它是 agent 的职责;如果一段代码在保证"做的事不出错、可追踪、可恢复",它就是 harness 的职责。函数调用(function calling)背后的调度循环、各类 Agent SDK 里的 tool-use 协议封装、多智能体框架里的消息转发,本质上都是 harness 在干活。没有座舱,再好的飞行员也会因为看不到仪表、够不到操纵杆而失事——这就是为什么"裸调模型"的项目做到后面全都在补 harness。
1.2 在这个语境下,组件到底指什么
你会发现"组件"这个词到处都是:前端组件库里的轮播图、视频、弹窗是组件;RPA 里的抓取、填表、发邮件是组件;组态王里的协议节点、ECharts 里的工具箱和详情提示框、打印模板里的控件同样都是组件。不管是 UI 组件、流程组件还是工控组件,背后的思想一致:一个组件就是一块"高内聚、可替换、有明确接口"的零件。不同领域的组件只是组装的对象不同——前端组装界面,RPA 组装流程,Agent Harness 组装的是决策执行链。
Agent Harness 的组件化也是这个思路,只不过它组装的不是界面,而是"决策执行链"。一个典型 harness 至少要有上下文、工具、记忆、日志、安全、任务追踪这些模块,每个模块都应该能独立替换。从工程角度看,把一个单体 agent 拆成组件,最大的好处是出问题能定位、要扩展能插拔。这也是为什么我把 harness 拆成 12 个组件而不是做成一个"巨型 Agent 类"的原因。
2. 用一个类比推导 12 个组件:把 Harness 想成飞机座舱
2.1 为什么偏偏是"座舱"这个类比
因为座舱是最典型的"中间层":它不替飞行员做决定,但把所有做决定需要的条件都准备好。把模型当成引擎之后,你会发现 harness 要管的恰恰就是座舱要管的那些事——状态显示、动作输入、自动巡航、导航、油量、除冰、通信、记录、货物管理、手册、副驾驶、乘客清单。一个部件对应一个工程问题,一个都不少。
这个类比还有一个额外的好处:它天然把"人"和"系统"分开。飞行员是决策者,座舱是支持系统,两者边界清晰。对应到代码里,agent 的 prompt、推理、规划是"人"的部分,而 harness 的组件是"系统"的部分。一旦你开始用这个视角看代码,就不会再把上下文拼接、工具调用这些脏活塞进 agent 的 prompt 里了。
2.2 12 个组件速览表
| 座舱部件 | Harness 组件 | 核心职责 |
|---|---|---|
| 仪表盘 | Telemetry / 可观测层 | 展示运行状态、指标、事件 |
| 黑匣子 | Tracker / 回放器 | 记录全量 trace,支持复现 |
| 操纵杆 | ToolExecutor | 执行工具调用、超时重试 |
| 导航 | Router / Orchestrator | 任务分解、路由到子流程 |
| 自动巡航 | AgentLoop / Planner | 主循环、规划与步骤控制 |
| 副驾驶 | Evaluator / Reflector | 关键节点评估与自我纠正 |
| 油量表 | ContextManager | Token 预算、裁剪、摘要 |
| 货舱清单 | MemoryStore | 短期 / 长期记忆存取 |
| 除冰系统 | Guardrails | 输入 / 输出安全护栏 |
| 无线电 | MessageBus | 组件与多 Agent 通信 |
| 维修手册 | PromptRegistry | 提示词、工具 schema 版本管理 |
| 乘客名单 | TaskQueue / GoalTracker | 目标、子任务、完成状态追踪 |
这张表是全文的"地图"。后面每一组都按"类比物是什么 → 对应工程问题是什么 → 落地时怎么设计"的顺序展开。
3. 12 个组件逐一拆解:设计意图与落地要点
3.1 仪表盘 + 黑匣子:可观测性与可回放性
仪表盘解决的是"现在什么状态"。调试 agent 时最痛苦的就是不知道它在干什么:卡在哪个步骤?调了哪个工具?花了多少 token?所以可观测层必须把每次模型请求、每个工具调用、每个阶段流转都变成结构化事件,并且带上 run_id、时间戳、耗时、token 数、状态码。别只打一行文本日志,文本没法筛选、没法聚合、没法画趋势,排障效率极低。
黑匣子解决的是"出问题怎么复现"。仪表盘是实时看,黑匣子是事后查。每一次进入模型前的 prompt、模型返回的原始内容、工具入参出参、护栏拦截记录,全部落盘。我的一点实践:所有组件统一走一个 trace 写入接口,不要各自打各自的文件,否则回放时对不上时间线。回放脚本把同一个 run_id 的日志按顺序重放,基本就能还原事故现场。
3.2 操纵杆 + 导航:动作执行与任务路由
操纵杆是 agent 接触真实世界的唯一通道。看 RPA 场景最直观:一个"填表"动作背后可能是一串 OCR、鼠标键盘、数据库操作,每一个都是工具。ToolExecutor 至少要管四件事:工具注册与 schema 发现、参数校验、超时与重试、错误归一化。工具返回的错误必须变成结构化形式(错误码 + 可读消息),不要抛裸异常,否则大模型看到一堆堆栈会晕头转向。
导航解决的是"这一个任务该走哪条链路"。它不该是 if-else 堆分支,而是先把目标分解成子任务,再做路由。简单项目可以用意图分类器把用户输入分到几个子流程;复杂项目就是编排器加子任务图。路由的结果进入任务队列(乘客名单),由自动巡航逐项消化。这里容易犯的错是把路由逻辑写死在 agent 的 prompt 里,让模型自己"猜"流程——一旦分支多了,模型一定会猜错。
3.3 自动巡航 + 副驾驶:主循环、规划与自我反思
自动巡航对应 AgentLoop / Planner。主循环只做一件事:决定"这一轮是继续调模型、执行工具,还是结束"。ReAct 是最常见的模态;更复杂的是 Plan-and-Execute,先让模型产出完整计划,再逐步执行、逐步校验。主循环必须带步数上限和终止条件,否则就是失控。我见过不少线上事故,本质都是这个循环没有边界,agent 在同一个问题上反复空转。
副驾驶是 Evaluator / Reflector。真正的反射不是最后写一段"你总结一下",而是在关键节点插入评估:工具结果回来后,判断"这个结果合理吗?要不要换个工具重试?要不要回退到上一步?"。评估结果驱动主循环回退,效果比"无限次重试"好得多。我的做法是用一条评估 prompt 加一个打分函数,输出不达标就再给模型一次修订机会,但最多修订两次,防止死循环。
3.4 油量监控 + 货舱:Token 预算与记忆系统
上下文窗口就是油箱。装得下的时候一切正常,一旦超了就各种截断、幻觉、丢信息。ContextManager 有四个常规手段:截断,丢最远且不重要的内容;摘要,把旧历史压成几句话;分页,把长文档拆块按需加载;外置,把大段内容放到检索 / 向量库里,需要时再取。预算要按轮次监控,接近阈值就降级,而不是等超限了才处理。
货舱是记忆系统。短期记忆就是本回合的观察记录;工作记忆是多轮会话状态,比如用户偏好、当前表单已填字段;长期记忆是知识库 / 档案,需要向量化、写索引。记忆最容易被忽略的是"什么时候忘":要有淘汰策略,否则记忆越积越多,反而冲淡关键信息。记忆系统必须定义稳定接口,以后才能从容地从内存实现切换到 Redis 或向量库。
3.5 除冰系统 + 无线电:安全护栏与通信总线
除冰系统就是 Guardrails。它必须出现在三个位置:用户输入进入前,过滤注入和敏感信息;模型输出返回后,检测格式错误与内容风险;工具执行前,校验参数,防止把用户注入的指令带进工具调用。只做输入过滤远远不够——间接提示注入经常藏在工具返回的网页内容里,必须在"模型输出 → 工具执行"这个环节再拦一道。
无线电是 MessageBus。组件之间、多 Agent 之间的通信统一走消息:消息有类型(request / reply / event)、有载荷、有超时、有 trace_id。不要允许组件直接调另一个组件的内部方法。通信语义上至少支持三种模式:显式注入、回调上报、发布订阅,后面我会专门展开讲组件通信。
3.6 维修手册 + 乘客名单:策略注册与目标追踪
维修手册是 PromptRegistry。system prompt、工具描述、few-shot 示例、策略规则,全部要像代码一样管理:有版本、有作者、可回滚、可灰度。最容易翻车的一个细节:工具 schema 改了但 prompt 里的描述没同步更新,模型就会按错误的格式调用。组件拆了但"文档没拆",等于白拆。
乘客名单是 TaskQueue / GoalTracker。长任务最怕迷失:用户目标是登机口,子任务是乘客。每个子任务要有状态(pending / running / done / blocked)、负责人(哪个工具或子 agent)、依赖关系。主循环每轮结束都检查队列:是否全部完成、是否有卡死、是否有重复任务。一个简单的字典加状态枚举就够,但它必须存在,否则 agent 会在长对话里原地打转。
4. 组件通信怎么设计:父传子、子传父与事件总线
4.1 三种通信模式
如果你写过 Vue3 组件,会发现 harness 的组件通信特别眼熟。第一种是显式注入,也就是父传子:外层 harness 把上下文、记忆、工具描述、配置直接传给 Agent,类似 props。第二种是回调上报,也就是子传父:工具执行完把结构化结果交回给调用方,类似 emit。第三种是全局总线:跨模块、跨 Agent 的事件广播,类似 provide / inject 或者事件总线。
原则是:能显式注入就不要上总线。总线会让数据流变得不可追踪,出了问题你根本不知道这个事件是谁发的、谁消费的。组件通信"父传子、子传父"这六个字,在 harness 里意味着"上下文往下传,结果往上回",这个顺序千万不能乱。
4.2 前端开发者最熟悉的一套对照
很多前端同学会问"vnodes 是一个组件吗",不是。VNode 只是"组件长什么样"的结构化描述,真正干活的是组件实例。harness 里的 ToolCall 同理:模型输出的 function_call 结构化对象,是"动作的描述",ToolExecutor 才是真正执行的实例。分层的好处是:描述可以序列化、可以存日志、可以重放,执行可以绑定真实副作用。
函数组件在 React / Vue 里是"无状态纯函数",在 harness 里对应无状态工具:输入参数、返回结果、无副作用。这种工具最好用,因为可以放心缓存、并发、重放。有状态的工具要显式声明,像 keep-alive 只对特定组件生效一样,harness 的缓存也只对无副作用组件生效。上下文缓存、工具结果缓存,都必须先问一句"这个组件有没有状态"。
动态组件加载就是 defineAsyncComponent 的思路:Agent 的工具也可以按需加载,MCP 插件也好、远程工具也好,先给模型一个精简目录,等模型真正选中再加载细节 schema。不要一次性把几百个工具的 schema 全塞进 prompt,那是油量表第一个报警。
4.3 一次完整工具调用的调用链
一次完整工具调用会依次穿过这些组件(括号里是座舱部件):Guardrail(除冰)→ Router(导航)→ ContextManager(油量)→ AgentLoop(自动巡航)→ LLM → 解析 ToolCall(VNode 层)→ Guardrail 二次检查 → ToolExecutor(操纵杆)→ 结果回写 ContextManager → 循环,直到 Evaluator 判定完成(副驾驶)→ TaskQueue 标记完成(乘客名单)→ Tracker 全程记录(黑匣子)。
这条链就是 harness 的标准流程,每一步都可插拔。调试时对照这条链,看日志停在哪一环,问题就出在哪个组件。我自己排查线上问题从来不看对话内容,只看 trace 停在哪一步——通常一眼就能定位。
5. 最小可运行 Harness:约 60 行代码串起 12 个组件
5.1 设计取舍
给一份最小实现,Python,大约 60 行。刻意选择显式依赖注入而不是全局单例:所有组件通过构造函数传入 Harness,测试时可以任意替换。也不引入第三方库,先把语义跑通。真实项目里可以把 ToolRegistry 换成 MCP 客户端,把 ContextManager 换成带摘要器的实现,接口不变。
from dataclasses import dataclass from typing import Callable, Optional import json, re @dataclass class Tool: name: str description: str fn: Callable class ToolRegistry: def __init__(self): self._map = {} def register(self, tool: Tool): self._map[tool.name] = tool def schemas(self) -> str: return "\n".join(f"- {t.name}: {t.description}" for t in self._map.values()) def invoke(self, name: str, **args): return self._map[name].fn(**args) class ContextManager: def __init__(self, budget: int = 3000): self.budget = budget self.segments = [] def add(self, segment: str): self.segments.append(segment) def build(self) -> str: total, built = 0, [] for seg in reversed(self.segments): # 保近丢远 total += len(seg) if total > self.budget: break built.insert(0, seg) return "\n\n".join(built) class Guardrail: def before(self, text: str) -> str: return text # 输入过滤、脱敏 def after(self, text: str) -> str: return text # 输出校验、注入检测 class Harness: def __init__(self, llm, tools: ToolRegistry, guards: list[Guardrail]): self.llm = llm self.tools = tools self.guards = guards self.trace = [] def run(self, task: str, max_steps: int = 8) -> str: ctx = ContextManager() ctx.add(f"可用工具:\n{self.tools.schemas()}") ctx.add(f"用户任务: {task}") for _ in range(max_steps): prompt = ctx.build() for g in self.guards: prompt = g.before(prompt) reply = self.llm(prompt) for g in self.guards: reply = g.after(reply) self.trace.append(reply) action = self._parse_action(reply) if action is None: return reply result = self.tools.invoke(action["tool"], **action.get("args", {})) ctx.add(f"工具[{action['tool']}]返回: {str(result)[:500]}") raise RuntimeError("超过最大步数") def _parse_action(self, reply: str) -> Optional[dict]: if "FINAL:" in reply: return None start, end = reply.find("<tool>"), reply.find("</tool>") if start == -1 or end == -1: return None name = reply[start + 6:end].strip() m = re.search(r"<args>(.*?)</args>", reply, re.S) args = json.loads(m.group(1)) if m else {} return {"tool": name, "args": args} def fake_llm(prompt: str) -> str: if "计算" in prompt and "<tool>" not in prompt: return '<tool>计算器</tool><args>{"a": 1, "b": 2}</args>' return "FINAL: 结果是 3" def calc(a: int, b: int) -> int: return a + b reg = ToolRegistry() reg.register(Tool("计算器", "两个数字相加", calc)) h = Harness(llm=fake_llm, tools=reg, guards=[]) print(h.run("帮我计算 1+2"))代码里有几个刻意埋的点。ContextManager 的 build 逻辑是"保近丢远",从后往前保留 segment 直到预算上限,这是最便宜的油量控制。Guardrail 用 before / after 两个钩子把安全位点固定住,后面加内容过滤、注入检测都往这里填。_parse_action 用简单的标签解析,真实项目要换成 function calling 的协议解析。trace 列表记录了每一轮模型原始输入输出,这就是黑匣子的雏形。
5.2 落地清单
| 组件 | 最小实现建议 | 生产增强建议 |
|---|---|---|
| Telemetry | 结构化日志 | OpenTelemetry / 指标面板 |
| Tracker | run_id + 事件列表 | 回放器 + trace 存储 |
| ToolExecutor | 函数字典 + 参数校验 | 超时 / 重试 / 熔断 / MCP |
| Router | if-elif 意图分发 | 编排器 / 子任务图 |
| AgentLoop | while + max_steps | Plan-and-Execute |
| Evaluator | 打分函数 | LLM-as-judge / 规则集 |
| ContextManager | 长度裁剪 | 摘要 / 分页 / 向量检索 |
| MemoryStore | 列表 + 淘汰策略 | Redis / 向量库 / 档案化 |
| Guardrails | before / after 钩子 | 策略引擎、脱敏、注入检测 |
| MessageBus | 事件列表 + 订阅 | 消息中间件 / 多 Agent 网关 |
| PromptRegistry | JSON 文件 + 版本号 | 配置中心 / 灰度 / 评审 |
| TaskQueue | 字典 + 状态枚举 | 状态机 / 分布式任务 |
想省事的话,可以直接去看 LangChain、OpenAI Agents SDK 里的循环实现,以及 Anthropic 的 tool-use 文档、各种 MCP 库——它们都是这套组件的具体落地,没必要重复造轮子。
6. 常见问题与排查技巧实录:让 Harness 少翻车
6.1 高频故障速查表
| 症状 | 大概率原因 | 解决方案 |
|---|---|---|
| 同一工具反复调用相同参数 | 没有任务去重和终止条件 | TaskQueue 记录已执行动作,主循环加 max_steps |
| 上下文越长越贵,越聊越笨 | 缺油量监控 | ContextManager 做预算裁剪 + 摘要 |
| 工具注册了模型却不会用 | schema 太啰嗦或 prompt 未同步 | 精简工具描述,PromptRegistry 统一版本 |
| 偶发超时直接失败 | 工具没有超时重试 | ToolExecutor 加 timeout 和退避重试 |
| 工具返回内容被注入后行为异常 | 只做了输入过滤 | 模型输出后、执行前再加 Guardrail |
| 事故无法复现 | 黑匣子没存结构化事件 | run_id + 全量 trace + 回放 |
一个组件的卡死会拖垮整个 Agent,就像 Steam UI 的某个 webhelper 组件没响应时整个界面都用不了,也像打印机提示定影组件需要更换时就整体停工——组件化架构的脆弱点就在"组件间依赖"。所以 harness 里所有外部交互,无论是模型、工具还是远程 Agent,都要有超时和熔断。这条排障经验最值钱。
6.2 四个实战避坑心得
第一,先做黑匣子再做聪明功能。日志先行,后面调 Evaluator、护栏,全靠 trace 判断效果。没有 trace 谈优化,都是在盲人摸象。
第二,护栏不是关键词过滤。关键词黑名单漏报多、误报也大,要叠加 schema 校验、脱敏、策略规则,甚至用小模型做分类。只靠黑名单的护栏,早晚会被绕过。
第三,别一上来就上消息总线。组件通信先走显式注入和回调,等项目真出现"跨组件状态同步"的痛点再引总线。过早引入总线,只会让代码变成一团谁都看不懂的事件网。
第四,工具描述要像代码一样走审查。改一个工具的返回结构,必须同步改描述、改 few-shot、改校验,和前端"改了组件 props 要升级文档"是同一个道理。工具 schema 与 prompt 描述必须同版本发布。
6.3 最后说点私货
最后说点个人体会。我最早写 Agent 就是一个巨大的 while 循环加满屏 if,功能看着能跑,但每次出问题都要在对话记录里人肉考古。重构成 harness 之后,最直接的收益不是功能变多,而是定位变快:看 trace 一眼就知道是哪一环、哪个组件、哪次调用出了问题。如果你也被"harness 到底是啥"这类问题问住,试试我这个座舱类比:飞行员(Agent)依然是核心,但没有仪表盘、操纵杆、黑匣子和护栏的飞行员,是上不了天的。后面想把 harness 从单 Agent 扩展成多 Agent,这套组件也基本不用推翻,只是把无线电换成真正的消息中间件,把乘客名单换成分布式队列而已。