1. 拆开 AI Agent 的“干活引擎”:Harness 到底是什么
很多人第一次听到 Harness 这个词,脑子里浮现的是线束、马具、安全带,跟 AI 八竿子打不着。但在 AI Agent 的语境里,Harness 指的是让模型真正“动起来”的那套执行骨架——它负责把大模型的推理能力,翻译成可落地、可循环、可观测的实际动作。你可以把它理解成 Agent 的“神经系统 + 肌肉骨骼”:LLM 是大脑,Harness 是让大脑的指令变成手脚动作的那一整套装置。
我刚开始接触 AI Agent 开发的时候,也走过弯路。那时候我以为只要把提示词写好、把工具函数注册进去,Agent 就能自己跑起来。结果实测下来,模型确实能输出“我要调用某个工具”的文本,但谁来解析这段文本?谁来执行工具?执行完的结果怎么塞回上下文?出错之后怎么重试?这些问题一个都没解决。后来我才明白,Agent 的能力上限,很大程度上不取决于模型本身,而取决于 Harness 的设计质量。
这篇文章适合三类人看:第一类是想从零搭建 AI Agent 但不知道从哪下手的开发者;第二类是用过现成框架但总觉得“黑盒感太重”、想搞清楚底层原理的工程师;第三类是正在做技术选型、需要判断某个 Agent 产品到底靠不靠谱的技术负责人。我会把 Harness 拆成 7 个核心子系统,逐个讲清楚它们各自负责什么、为什么这样设计、实操中怎么落地,以及我踩过的那些坑。
需要提前说明的是,不同框架对 Harness 的模块划分方式不完全一样,有的把某些子系统合并了,有的拆得更细。我这里给出的 7 个子系统划分,是基于常见工程实践的一种合理归纳,目的是让读者有一个清晰的认知地图,而不是说所有框架都必须严格按这个来。
2. 七个核心子系统的整体设计与协作逻辑
2.1 为什么是这七个,而不是五个或十个
在拆解具体子系统之前,先说说这套划分背后的逻辑。一个能真正干活的 AI Agent,本质上要解决四个问题:想什么、做什么、记得住、不出乱子。“想什么”对应推理与决策,“做什么”对应工具调用与执行,“记得住”对应上下文与状态管理,“不出乱子”对应错误处理与安全边界。把这四个问题进一步细化,就得到了七个相对独立但又紧密协作的子系统。
我见过一些开发者一上来就想搞一个大而全的 Agent 类,把所有逻辑塞进一个文件里。刚开始跑 demo 没问题,一旦要加新工具、要换模型、要处理并发,代码就变成了一团乱麻。按子系统拆分的好处是,每个模块的职责边界清晰,替换和升级的成本低。比如你今天用 A 模型,明天想换成 B 模型,只需要动 LLM 集成层,其他六个子系统基本不用改。
另一个常见误区是过度设计。有些人一开始就想着要做多 Agent 协作、要做复杂的规划树,结果连最基本的单轮工具调用都没跑通。我的建议是,先把七个子系统的最小可用版本跑通,再根据实际需求逐步增强。下面这张表是我总结的七个子系统及其核心职责,先建立一个全局印象。
| 子系统 | 核心职责 | 一句话类比 |
|---|---|---|
| Agent Loop | 驱动整个“推理-行动-观察”循环 | 心脏起搏器 |
| LLM Integration | 对接大模型接口,处理输入输出 | 翻译官 |
| Tool Registry | 注册、描述、路由所有可用工具 | 工具箱 |
| Memory & Context | 管理短期对话与长期知识 | 记忆中枢 |
| Planner | 任务分解与执行顺序编排 | 项目经理 |
| Executor | 实际执行工具调用并处理结果 | 施工队 |
| Guardrail | 安全校验、权限控制、异常兜底 | 安全阀 |
2.2 子系统之间的数据流与控制流
这七个不是孤立存在的,它们之间的协作方式决定了 Agent 的整体行为。我用一个典型的“用户提问→Agent 回答”流程来说明:用户输入首先进入Agent Loop,Loop 把输入交给LLM Integration组装成模型能理解的格式;模型返回的响应如果包含工具调用意图,Planner会判断是否需要拆解成多步;然后Tool Registry根据工具名称找到对应的执行函数,交给Executor去跑;执行结果经过Guardrail校验后,写回Memory & Context;Loop 判断是否还需要继续循环,直到模型给出最终答案。
这个流程看起来线性,但实际上有很多分支和回环。比如 Executor 执行失败,Guardrail 可能会触发重试或降级策略;Planner 发现当前任务太复杂,可能会插入一个子任务规划步骤;Memory 在上下文快满的时候,会触发摘要压缩。理解这些数据流,是排查 Agent 行为异常的基础。很多时候 Agent “不听话”,不是模型的问题,而是某个子系统的数据传递出了偏差。
3. 核心子系统逐个拆解与实操要点
3.1 Agent Loop:整个系统的心跳
Agent Loop 是 Harness 里最核心的子系统,它决定了 Agent 是“一问一答”还是“自主循环”。最简单的 Loop 就是一个 while 循环:调用模型→解析输出→如果有工具调用就执行→把结果拼回上下文→再次调用模型→直到模型输出最终答案或达到最大轮次。
听起来简单,但实操中有几个关键决策点。第一个是最大循环轮次的设定。设太小,复杂任务跑不完;设太大,可能陷入死循环烧钱。我的经验是,一般任务设 10 到 15 轮比较合理,复杂任务可以放宽到 25 轮,但一定要配合超时机制。第二个是循环终止条件的判断。不能只看模型是否输出了“最终答案”标记,还要检查是否连续多轮没有实质性进展。我通常会加一个“无进展计数器”,如果连续 3 轮工具调用返回的结果高度相似,就强制终止并返回当前最佳结果。
# Agent Loop 的核心骨架示意 max_turns = 15 no_progress_count = 0 last_results = [] for turn in range(max_turns): response = llm_integration.call(context) if response.is_final_answer: return response.content if response.has_tool_calls: results = executor.run(response.tool_calls) # 无进展检测 if results == last_results: no_progress_count += 1 if no_progress_count >= 3: return "任务未能取得进一步进展,当前结果:" + str(results) else: no_progress_count = 0 last_results = results context = memory.update(context, response, results)注意:循环轮次和超时时间一定要做成可配置参数,不要硬编码。不同任务类型对这两个值的敏感度差异很大。
3.2 LLM Integration:别小看这层“翻译官”
LLM Integration 看起来只是调个 API,但实际上这层的设计质量直接影响 Agent 的稳定性。核心要解决的问题有三个:格式适配、流式处理、降级容错。
格式适配方面,不同模型对消息格式的要求不一样。有的要求 system prompt 单独放,有的要求工具描述用特定 JSON Schema,有的对多轮对话的角色顺序有严格要求。我建议在这一层做一个统一的内部消息格式,然后针对每个模型写一个适配器,把内部格式转成目标模型要求的格式。这样换模型的时候只需要加一个适配器,不用改上层逻辑。
流式处理是另一个容易被忽视的点。Agent 场景下,流式输出不只是为了用户体验,更重要的是可以在流式过程中提前解析工具调用意图,减少等待时间。但流式解析的复杂度比一次性解析高不少,需要处理 JSON 被截断的情况。我的做法是,在流式缓冲区里维护一个括号计数器,只有括号完全闭合才尝试解析。
降级容错方面,模型 API 超时、限流、返回格式异常都是家常便饭。一定要设置重试机制,但重试次数不要超过 3 次,且要加指数退避。如果重试后仍然失败,要有降级方案,比如切换到备用模型,或者返回一个友好的错误提示而不是直接崩溃。
3.3 Tool Registry:工具多了怎么不乱
Tool Registry 负责管理 Agent 能用的所有工具。刚开始只有三五个工具的时候,怎么写都行。但当工具数量增长到几十个的时候,问题就来了:模型怎么知道该选哪个工具?工具描述怎么写才能让模型准确理解?工具之间有重叠怎么办?
我的经验是,工具描述要遵循“三要素”原则:做什么、什么时候用、输入输出是什么。不要只写“查询天气”,而要写“根据城市名称查询当前天气状况,适用于用户询问某地天气、温度、是否下雨等场景,输入为城市名称字符串,输出为温度、天气状况、湿度”。描述越具体,模型选错的概率越低。
工具数量超过 20 个之后,建议做分组路由。先让模型判断任务属于哪个类别,再在类别内选择具体工具。这样可以显著降低模型的认知负担。另外,工具命名要有规律,比如统一用“动词_名词”的格式,避免出现“getWeather”和“weatherQuery”这种风格不一致的命名。
| 工具描述写法 | 模型选择准确率(实测) |
|---|---|
| 仅写工具名 | 约 60% |
| 工具名 + 一句话描述 | 约 75% |
| 工具名 + 三要素完整描述 | 约 90% |
| 三要素 + 分组路由 | 约 95% |
3.4 Memory & Context:记住该记的,忘掉该忘的
Memory 子系统要解决的核心矛盾是:上下文窗口有限,但对话历史和知识可能是无限的。我见过太多 Agent 因为上下文塞太满导致模型“失忆”或者响应变慢。
我的做法是把 Memory 分成三层:即时上下文、工作记忆、长期记忆。即时上下文就是最近几轮的对话,原封不动保留;工作记忆是当前任务的中间结果,用结构化格式存储;长期记忆是跨会话的知识,用向量数据库存储,需要的时候检索注入。
上下文压缩的时机很关键。不要等到快满了才压缩,那样容易丢关键信息。我通常设置在上下文使用率达到 70% 的时候触发压缩。压缩策略上,优先保留工具调用的结果和用户的明确指令,对话寒暄和重复确认可以大胆丢弃。
提示:压缩后的摘要一定要保留原始消息的索引,方便需要时回溯。我吃过这个亏,压缩完发现某个关键参数丢了,但原始消息已经找不回来了。
3.5 Planner:什么时候需要,什么时候不需要
Planner 子系统负责把复杂任务拆解成可执行的步骤。但我要泼一盆冷水:不是所有 Agent 都需要 Planner。如果你的任务都是单步就能完成的,加 Planner 只会增加延迟和出错概率。
需要 Planner 的典型场景是:任务需要多步推理、步骤之间有依赖关系、需要动态调整执行顺序。比如“帮我分析这份销售数据并生成报告”,这就涉及读文件、清洗数据、计算指标、生成图表、撰写文字等多个步骤,适合用 Planner 先规划再执行。
Planner 的实现方式有两种主流选择:一种是让 LLM 直接输出步骤列表,另一种是用专门的规划算法。前者灵活但稳定性差,后者稳定但不够灵活。我的建议是,大部分场景用 LLM 规划就够了,但要在 Prompt 里明确要求输出结构化的步骤列表,并且加上步骤数量上限,防止模型规划出几十步的“宏伟计划”。
3.6 Executor:真正干活的那双手
Executor 是实际执行工具调用的子系统。这层最容易被低估,很多人觉得不就是调个函数吗?但实操中,超时处理、并发控制、结果格式化、副作用管理都是坑。
超时处理方面,每个工具都应该有独立的超时设置。查询类工具可以设短一点,比如 5 秒;涉及外部系统的操作可以设长一点,比如 30 秒。超时后要返回明确的错误信息,而不是让整个 Agent 卡死。
并发控制方面,如果 Planner 规划出的多个步骤之间没有依赖关系,可以并发执行来提速。但要注意,有副作用的操作(比如写文件、发请求)不要轻易并发,除非你确定它们之间不会互相干扰。
结果格式化方面,工具返回的结果要统一成模型能理解的格式。我建议统一用 JSON,并且控制返回内容的长度。有些工具返回一大堆原始数据,直接塞给模型既浪费 token 又干扰判断。在 Executor 层做一次结果裁剪和摘要,是非常值得的投入。
3.7 Guardrail:不出事的时候感觉不到,出事的时候救命
Guardrail 是安全阀,负责权限控制、输入输出校验、异常兜底。这层在 demo 阶段经常被省略,但一旦上生产,没有 Guardrail 的 Agent 就是一个定时炸弹。
权限控制方面,要给每个工具标注风险等级。低风险工具(如查询类)可以直接执行,高风险工具(如删除、支付、发送)需要额外确认或限制调用频率。输入校验方面,要防止提示词注入和参数注入。输出校验方面,要过滤敏感信息和不合规内容。
异常兜底方面,要定义清晰的降级策略。模型调用失败怎么办?工具执行超时怎么办?返回结果格式异常怎么办?这些都要有预案。我的做法是,Guardrail 层维护一个“安全状态机”,任何异常都先进入安全状态,返回保守的响应,而不是让异常向上传播导致整个 Agent 崩溃。
4. 从零搭建一个最小可用 Harness 的实操过程
4.1 环境准备与依赖选择
搭建 Harness 不需要特别重的依赖。核心就是三样:一个能调模型 API 的 HTTP 客户端、一个工具注册与执行的框架、一个状态管理的数据结构。语言方面,Python 生态最成熟,JavaScript/TypeScript 也不错,选你团队最熟悉的就行。
我个人的技术选型习惯是:Python + Pydantic(做数据校验和序列化)+ FastAPI(如果需要暴露 HTTP 接口)。Pydantic 在定义工具输入输出 schema 的时候特别好用,能自动生成 JSON Schema,省去手写描述的工作。如果你不想引入太多依赖,用标准库的 dataclasses 也能凑合,但校验和序列化要自己多写点代码。
目录结构上,我建议按子系统分目录,每个子系统一个模块。这样后期维护和替换都方便。下面是我常用的目录结构:
harness/ loop/ agent_loop.py llm/ base.py adapter_openai.py adapter_anthropic.py tools/ registry.py builtin/ search.py calculator.py memory/ context_manager.py long_term.py planner/ simple_planner.py executor/ runner.py guardrail/ policy.py validator.py4.2 最小闭环的代码实现
先跑通一个最小闭环:用户输入→模型调用→工具执行→结果返回。不要一上来就搞 Planner 和长期记忆,那些后面再加。
第一步,定义内部消息格式。我通常用这样的结构:
from dataclasses import dataclass, field from typing import Literal @dataclass class Message: role: Literal["system", "user", "assistant", "tool"] content: str tool_calls: list = field(default_factory=list) tool_call_id: str = ""第二步,实现 LLM 适配器。以 OpenAI 风格的接口为例:
class LLMAdapter: def __init__(self, api_key, base_url, model): self.client = HttpClient(api_key, base_url) self.model = model def call(self, messages, tools): payload = { "model": self.model, "messages": [self._to_api_format(m) for m in messages], "tools": [t.to_schema() for t in tools] if tools else None } response = self.client.post("/chat/completions", payload) return self._parse_response(response)第三步,实现工具注册表:
class ToolRegistry: def __init__(self): self._tools = {} def register(self, name, description, parameters, func): self._tools[name] = { "name": name, "description": description, "parameters": parameters, "func": func } def get(self, name): return self._tools.get(name) def all_schemas(self): return [ {"type": "function", "function": { "name": t["name"], "description": t["description"], "parameters": t["parameters"] }} for t in self._tools.values() ]第四步,实现 Agent Loop:
class AgentLoop: def __init__(self, llm, registry, executor, max_turns=15): self.llm = llm self.registry = registry self.executor = executor self.max_turns = max_turns def run(self, user_input): messages = [Message(role="user", content=user_input)] for _ in range(self.max_turns): response = self.llm.call(messages, self.registry.all_schemas()) if not response.tool_calls: return response.content messages.append(response) for call in response.tool_calls: result = self.executor.run(call) messages.append(Message( role="tool", content=result, tool_call_id=call.id )) return "达到最大循环轮次,任务未完成"这四步跑通,你就有了一个能干活的最小 Harness。虽然简陋,但核心机制都在了。接下来就是根据实际需求,逐个增强各个子系统。
4.3 参数调优与性能优化
最小闭环跑通之后,你会发现响应速度可能不太理想。优化方向主要有三个:减少不必要的模型调用、并行化工具执行、缓存重复结果。
减少模型调用方面,一个常见的浪费是每轮都把完整的工具 schema 塞给模型。如果工具数量多,这部分 token 消耗很可观。我的做法是,根据当前对话上下文,动态筛选相关工具。比如用户问的是天气,就只传天气相关的工具 schema,其他工具不传。这样既省 token 又提高选择准确率。
并行化工具执行方面,如果模型一次返回了多个没有依赖关系的工具调用,可以用线程池或异步 IO 并发执行。实测下来,3 个各耗时 2 秒的工具,串行要 6 秒,并行只要 2 秒多。但要注意,有副作用的工具不要并行,比如两个工具都要写同一个文件,并行会出问题。
缓存方面,对于查询类工具,如果同样的参数在短时间内被多次调用,可以直接返回缓存结果。我用一个简单的 LRU 缓存就够用了,缓存时间设 5 到 10 分钟比较合理。
5. 常见问题与排查技巧实录
5.1 模型不调用工具,只输出文本
这是最常见的问题。模型明明看到了工具定义,但就是不用,而是直接用自己的知识回答。原因通常有三个:工具描述不够清晰、系统提示词没有强调工具使用、模型本身能力不足。
排查顺序上,先检查工具描述。把工具描述读一遍,问自己:一个新人看到这段描述,能不能准确判断什么时候该用这个工具?如果答案是否定的,就改描述。然后在系统提示词里明确写“当需要获取实时信息或执行操作时,必须调用相应工具,不要依赖自身知识”。如果还不行,换一个工具调用能力更强的模型试试。
5.2 工具调用参数格式错误
模型返回的工具调用参数不符合 schema,比如该传数字的传了字符串,该传数组的传了单个值。这个问题在模型能力较弱的时候特别常见。解决思路是在 Executor 层做参数容错处理:能自动转换的就转换,不能转换的返回明确的错误信息让模型重试。
我通常会写一个参数校验和修复函数,处理常见的类型不匹配、缺少必填字段、多余字段等情况。对于缺少必填字段的情况,不要直接报错,而是返回“缺少参数 X,请补充”这样的提示,让模型有机会修正。
5.3 循环停不下来
Agent 陷入死循环,反复调用同一个工具,或者在不同工具之间来回跳转。这个问题很烦人,因为烧钱还不出结果。根本原因是缺少有效的终止判断。
我的解决方案是三层防护:第一层是最大轮次限制,硬性截断;第二层是无进展检测,连续多轮结果相似就终止;第三层是重复调用检测,同一个工具用同样的参数调用超过 3 次就拦截。这三层加上去,基本不会出现无限循环的情况。
| 问题现象 | 可能原因 | 排查动作 | 解决方案 |
|---|---|---|---|
| 模型不调用工具 | 描述不清/提示词未强调 | 检查工具描述和系统提示 | 改描述、加提示、换模型 |
| 参数格式错误 | 模型能力不足/schema 复杂 | 查看原始返回 | 加参数修复层、简化 schema |
| 循环停不下来 | 缺少终止判断 | 看循环日志 | 加轮次限制、无进展检测 |
| 响应速度慢 | 串行执行/上下文过长 | 看耗时分布 | 并行化、压缩上下文 |
| 结果不准确 | 工具返回格式差/上下文丢失 | 检查工具输出 | 格式化结果、优化记忆 |
5.4 上下文丢失导致“失忆”
多轮对话后,Agent 忘记了前面说过的关键信息。这个问题通常是上下文压缩策略太激进导致的。压缩的时候一定要保留用户的核心指令和工具调用的关键结果,寒暄和重复确认可以丢。
我还会在 Memory 层加一个“关键信息提取”步骤,每轮对话结束后,自动提取出实体、参数、约束条件等关键信息,单独存储。即使上下文被压缩了,这些关键信息仍然保留,需要的时候可以重新注入。
5.5 工具执行超时或报错
外部工具不稳定是常态。Executor 层必须对每个工具调用做超时控制和异常捕获。超时后返回“工具执行超时”而不是让整个流程卡住。异常捕获后返回错误信息,让模型决定是重试还是换方案。
我还会给每个工具设置一个“健康状态”标记。如果某个工具连续多次失败,就暂时从可用工具列表中移除,避免模型反复尝试一个已经挂掉的工具。
6. 子系统之间的边界与协作经验
6.1 哪些逻辑该放哪层,别搞混了
在实操中,最容易搞混的是 Planner 和 Agent Loop 的边界。我的原则是:Agent Loop 负责“要不要继续”,Planner 负责“下一步做什么”。Loop 是驱动引擎,Planner 是导航仪。Loop 判断当前是否已经得到最终答案,如果没有,就问 Planner 下一步该干什么。
另一个容易混淆的是 Memory 和 Context 的边界。我的划分是:Context 管当前会话的即时消息,Memory 管跨会话的持久化知识。Context 满了要压缩,Memory 则是按需检索。两者通过一个统一的接口对上层暴露,上层不用关心具体存在哪里。
Guardrail 和 Executor 的边界也要清晰。Executor 只管执行,Guardrail 管能不能执行、执行结果能不能用。不要在 Executor 里写权限判断逻辑,那样会让 Executor 变得臃肿且难以测试。
6.2 子系统之间的接口设计原则
接口设计上,我遵循三个原则:输入输出明确、错误处理统一、可观测性内置。每个子系统的方法签名要清晰表达它需要什么、返回什么。错误处理统一用异常或 Result 类型,不要有的返回 None 有的抛异常。可观测性方面,每个关键操作都要打日志或埋点,方便排查问题。
我特别想强调的是可观测性。Agent 的行为链路很长,出了问题如果没有日志,排查起来非常痛苦。我的做法是给每个子系统加一个统一的 trace_id,所有日志都带上这个 id,这样可以把一次完整请求的所有日志串起来看。
6.3 什么时候该拆分,什么时候该合并
不是所有项目都需要完整的七个子系统。小项目可以合并,大项目才需要拆分。比如一个简单的问答 Agent,Agent Loop 和 LLM Integration 可以合在一起,Planner 可以省略,Guardrail 可以简化成几个校验函数。
判断标准是:如果某个子系统的逻辑开始变得复杂,或者需要独立替换和测试,就把它拆出来。不要为了架构而架构,也不要因为一开始简单就永远不拆。我通常的做法是,先用一个文件跑通,当这个文件超过 500 行或者修改频率明显增高的时候,就开始按子系统拆分。
7. 关于 Harness 设计的一些个人体会
踩了这么多坑之后,我最大的体会是:Harness 的核心价值不在于功能多,而在于稳定和可观测。一个功能简单但稳定运行的 Harness,比一个功能花哨但经常出问题的 Harness 有价值得多。我见过太多项目在 demo 阶段很惊艳,一上生产就各种问题,根源往往不是模型不行,而是 Harness 的工程化程度不够。
另一个体会是,不要过早追求多 Agent 协作。单 Agent 的 Harness 跑稳了,再考虑多 Agent。多 Agent 带来的复杂度是指数级上升的,通信、协调、冲突解决都是难题。我见过不少团队在单 Agent 还没跑通的情况下就上多 Agent,结果项目烂尾。
最后分享一个小技巧:给你的 Harness 写一套“回归测试用例”。每次修改某个子系统之后,跑一遍测试用例,确保没有破坏已有功能。这些用例不需要很复杂,覆盖典型场景就行。我维护了一个包含 20 多个场景的测试集,每次改动后跑一遍,省去了大量手动验证的时间。这个习惯让我在快速迭代的同时,保持了系统的稳定性。