☰
AI Agent Harness 拆解:7 大核心子系统与工程实践
2026/11/3 10:50:09 网站建设 项目流程

1. 拆开 AI Agent 的引擎盖:Harness 到底在管什么

很多人第一次听到 Harness 这个词,脑子里浮现的是线束、马具、测试夹具。放在 AI Agent 的语境里,它其实更接近“整车的底盘加线束加仪表盘”——模型是发动机,Harness 是把发动机的动力真正传到轮子上的那一整套传动系统。你给一个大模型接上工具、记忆、循环控制、错误恢复,让它能自己规划、自己调工具、自己检查结果,这一整套运行时骨架,就是 Harness。

我见过太多人搭 Agent 的方式是:写一个 while 循环,把用户输入塞给模型,模型返回 tool_call 就执行,执行完再塞回去,直到模型说“我完成了”。跑 demo 没问题,一上真实任务就崩:工具报错没人管、上下文越滚越长、模型开始胡编工具名、同一个动作重复调用十几次、任务跑一半进程挂了状态全丢。这些问题不是模型不够聪明,是 Harness 没搭好。

所以这篇我想干一件事:把 Harness 拆成 7 个真正干活的子系统,讲清楚每个子系统负责什么、为什么必须单独拎出来、以及我在实际搭建时踩过的坑。核心关键词就几个:AI Agent、Harness、子系统、Agent Loop、LLM Integration。适合正在从 0 到 1 搭 Agent 的人,也适合已经搭了个能跑的版本、但总觉得“哪里不对劲”的人。读完你至少能判断:自己那套 Agent 缺了哪块,以及补上之后大概能解决什么问题。

先说结论性的认知:Harness 不是“调用大模型的那层封装”,它是让 Agent 从一次性问答变成可持续执行任务的全部工程设施。模型负责决策,Harness 负责让决策可执行、可观测、可恢复、可收敛。下面这 7 个子系统,就是我认为一个能真正下地干活的 Harness 的最小完整集合。

2. 七个核心子系统的整体设计与选型逻辑

2.1 为什么是 7 个,而不是 3 个或 15 个

拆子系统这件事,拆少了会耦合,拆多了会过度设计。我试过把 Harness 压成三个模块:循环、工具、上下文。结果发现错误处理没地方放,只能塞进循环里,循环代码迅速膨胀到几百行,改一处崩三处。后来我又试过拆成十几个微模块,每个模块都很薄,结果模块之间的调用关系变成一张蜘蛛网,调试时根本追不清一次工具调用到底经过了哪些层。

7 这个数字是我反复调整后觉得比较舒服的平衡点。它覆盖了 Agent 运行时的完整生命周期:从接收输入、组装上下文、调用模型、解析动作、执行工具、处理异常,到维护状态和可观测性。每个子系统职责单一,边界清晰,互相之间通过明确的数据结构通信,而不是靠全局变量或者隐式约定。

这 7 个子系统分别是:Agent Loop(主循环)、LLM Integration(模型接入层)、Context Management(上下文管理)、Tool Runtime(工具运行时)、Memory & State(记忆与状态)、Error Handling & Recovery(错误处理与恢复)、Observability(可观测性)。下面逐个拆。

2.2 子系统之间的数据流长什么样

理解 Harness 最好的方式是看一次完整的任务执行数据流。用户输入一句话进来,先经过 Context Management 组装成模型能吃的消息序列,交给 LLM Integration 发起推理,模型返回的内容里可能包含工具调用意图,Agent Loop 解析出这个意图,交给 Tool Runtime 去实际执行,执行结果再回流到 Context Management,进入下一轮循环。整个过程里,Memory & State 在后台记录关键状态,Error Handling 在每一层兜底,Observability 在旁边记录每一步的耗时、token 消耗和结果。

这个数据流里有个容易被忽略的点:工具执行结果不是直接拼回消息列表就完事。结果需要经过裁剪、格式化、必要时截断,否则一个返回几万字的工具结果会把上下文瞬间撑爆。这就是为什么 Context Management 必须独立成子系统,它不只是“拼消息”,还承担着上下文预算分配的责任。

2.3 选型时最容易犯的错:把 Harness 和框架混为一谈

市面上有很多 Agent 框架,LangChain、LangGraph、各种国产智能体平台。很多人以为用了框架就等于有了 Harness,其实不是。框架提供的是积木,Harness 是你用积木搭出来的那台机器。同一个框架,不同人搭出来的 Harness 质量差十倍。框架帮你解决了“怎么调模型”“怎么定义工具”这些标准化问题,但循环怎么终止、错误怎么恢复、上下文怎么压缩、状态怎么持久化,这些真正决定 Agent 能不能干活的问题,框架往往只给个默认实现,默认实现通常不够用。

我的建议是:先用框架快速跑通,然后把这 7 个子系统逐个替换成自己可控的实现。不要一上来就手写全部,那是浪费时间;也不要永远停留在框架默认实现,那你的 Agent 永远只能跑 demo。

3. 逐个拆解:每个子系统到底怎么干活

3.1 Agent Loop:整个 Harness 的心脏

Agent Loop 是主循环,它决定了 Agent 的“思考-行动-观察”节奏。最朴素的实现就是一个 while 循环,但真正能用的 Loop 必须回答几个问题:什么时候继续循环、什么时候终止、单次任务最多循环多少轮、循环过程中如何插入人工确认。

终止条件是 Loop 设计里最关键的。模型自己说“任务完成”不能作为唯一终止条件,因为模型会撒谎,也会陷入“我快完成了再试一次”的死循环。我的做法是三重终止:模型显式返回终止信号、达到最大轮数上限、检测到连续 N 轮没有产生有效动作。三个条件满足任意一个就停。最大轮数我一般设 15 到 25 之间,取决于任务复杂度,简单问答类 5 轮足够,复杂多步任务给到 30 轮。

MAX_TURNS = 20 NO_PROGRESS_LIMIT = 3 turn = 0 no_progress = 0 while turn < MAX_TURNS: response = llm_integration.invoke(context) if response.is_final: break action = parse_action(response) if action is None: no_progress += 1 if no_progress >= NO_PROGRESS_LIMIT: break else: no_progress = 0 result = tool_runtime.execute(action) context.append(result) turn += 1

这段代码看着简单,但每一行背后都有讲究。parse_action要能处理模型返回的各种畸形格式,tool_runtime.execute要能捕获所有异常并返回结构化错误而不是直接抛出,context.append要触发上下文预算检查。Loop 本身应该保持薄,把复杂度推给其他子系统。

实操心得:Loop 里千万不要写业务逻辑。我见过有人在 Loop 里判断“如果工具是搜索就多给点上下文”,这种逻辑一旦散落在 Loop 里,后面根本没法维护。所有这类判断都应该下沉到对应子系统。

3.2 LLM Integration:模型接入层的稳定性设计

LLM Integration 负责和模型 API 打交道。听起来简单,实际上这是最容易出稳定性问题的地方。网络抖动、限流、超时、返回格式异常、token 超限,每一种都能让 Agent 卡死。这一层的核心任务是:把不可靠的模型调用包装成可靠的函数调用。

重试策略是基础。但不是所有错误都值得重试。限流和超时值得重试,参数错误和内容违规不值得。我一般用指数退避,第一次等 1 秒,第二次 2 秒,第三次 4 秒,最多重试 3 次。重试时要带上 jitter,避免多个 Agent 实例同时重试造成雪崩。

import time, random def invoke_with_retry(messages, max_retries=3): for attempt in range(max_retries): try: return client.chat(messages) except RateLimitError: wait = (2 ** attempt) + random.uniform(0, 1) time.sleep(wait) except TimeoutError: if attempt == max_retries - 1: raise time.sleep(2 ** attempt) except InvalidRequestError: raise # 参数问题重试无意义 raise MaxRetriesExceeded()

除了重试,这一层还要做模型输出的规范化。不同模型返回工具调用的格式不一样,有的用 JSON,有的用特定标记,有的直接在文本里写。LLM Integration 要把这些统一成内部标准格式,让上层 Loop 不用关心底层是哪个模型。这样你换模型的时候,只需要改这一层。

还有一个细节:token 计数和预算控制。每次调用前要估算输入 token,超过模型上限就要触发上下文压缩。这个估算不用特别精确,按字符数除以 3 到 4 粗估就够,但要留足安全边际,我一般按上限的 80% 来卡。

3.3 Context Management:上下文不是越多越好

上下文管理是 Harness 里最被低估的子系统。很多人觉得上下文就是消息列表,往里 append 就行。结果跑十几轮之后,上下文几万 token,模型开始注意力涣散,关键信息被淹没在大量工具返回结果里。

Context Management 的核心职责是在有限预算内保留最有价值的信息。我的做法是分层管理:系统提示词永远保留,最近 N 轮对话完整保留,更早的对话做摘要压缩,工具返回结果超过阈值就截断并保留头尾。摘要压缩用一个小模型来做,成本低,效果够用。

上下文层级保留策略典型预算占比
系统提示词完整保留10%
最近 3 轮对话完整保留30%
更早对话摘要压缩20%
工具返回结果截断保留头尾30%
预留空间不占用10%

这个分配不是死的,要根据任务类型调整。代码生成类任务工具返回结果占比要高,对话类任务历史对话占比要高。关键是要有预算意识,不能任由上下文无限增长。

注意:摘要压缩会丢失信息,所以压缩前要把关键结论提取出来单独存到 Memory 里。我踩过的坑是:压缩后模型忘了之前确认过的关键参数,导致重复询问用户。后来我在压缩时强制保留“已确认事实”列表,问题就解决了。

3.4 Tool Runtime:工具执行的安全边界

Tool Runtime 负责实际执行工具调用。这一层最重要的设计原则是:工具执行必须和主循环隔离。工具可能超时、可能抛异常、可能返回超大结果、可能有副作用,这些都不能影响主循环的稳定性。

每个工具调用都应该有超时控制。我一般给工具设 30 秒超时,搜索类工具可以放宽到 60 秒。超时后不是直接失败,而是返回一个“超时”的结构化结果给模型,让模型决定是重试还是换方案。这比直接抛异常中断整个任务要好得多。

工具返回结果要强制走一遍结果处理器:截断超长内容、过滤敏感信息、格式化结构化数据。我见过工具返回一个巨大的 JSON,直接把上下文撑爆的情况。结果处理器里设一个字符上限,比如 4000 字符,超过就截断并标注“结果已截断”。

def execute_tool(tool_name, params, timeout=30): tool = registry.get(tool_name) if tool is None: return {"error": f"未知工具: {tool_name}", "retryable": False} try: raw = run_with_timeout(tool, params, timeout) return {"result": truncate(raw, 4000), "error": None} except TimeoutError: return {"error": "工具执行超时", "retryable": True} except Exception as e: return {"error": str(e), "retryable": False}

工具注册表本身也要设计好。每个工具要有清晰的名称、描述、参数 schema,这些信息会进入系统提示词供模型选择。工具描述写得好不好,直接决定模型会不会正确调用。我一般要求工具描述里包含:这个工具干什么、什么时候用、参数含义、返回什么。描述模糊的工具,模型调用错误率极高。

3.5 Memory & State:让 Agent 记得住、接得上

Memory 和 State 经常被混为一谈,其实是两件事。State 是当前任务的执行状态,比如“已经完成了哪几步”“当前在等什么”“临时变量是什么”。Memory 是跨任务、跨会话的长期记忆,比如“用户偏好”“历史结论”“领域知识”。

State 的关键是可持久化。Agent 跑长任务时进程可能挂掉,重启后要能从上次的状态继续。我的做法是每轮循环结束后把 State 序列化存到数据库或文件,重启时加载。State 里要存的东西包括:当前轮数、已完成步骤、待办步骤、关键中间结果、上下文摘要。不要存完整上下文,太大了,存摘要就够。

Memory 的关键是检索效率。长期记忆可能积累几千条,每次任务都全量塞进上下文不现实。要用向量检索或者关键词检索,只取最相关的几条。我一般取 top 5,太多会干扰模型判断。Memory 的写入也要克制,不是所有信息都值得记,只记那些“下次可能用到”的结论性信息。

实操心得:Memory 写入时一定要带时间戳和来源标记。我遇到过 Memory 里存了过时的信息,模型基于过时信息做决策,结果全错。后来加了时间戳,检索时优先取最近的,问题少了很多。

3.6 Error Handling & Recovery:错误不是异常,是常态

在 Agent 场景里,错误是常态而不是异常。模型会返回畸形格式、工具会失败、网络会抖动、上下文会超限。Error Handling 子系统的设计目标不是“避免错误”,而是“让错误不中断任务”。

错误要分类处理。我一般分三类:可重试错误(超时、限流)、可降级错误(工具不可用,换备用工具)、致命错误(参数错误、权限不足)。可重试的自动重试,可降级的走备用路径,致命的返回给模型让它重新规划。

关键设计是错误要作为观察结果回流给模型,而不是直接抛给用户。模型看到“搜索工具超时了”,它可能会决定换个关键词重试,或者用另一个工具。这比直接告诉用户“搜索失败了”要智能得多。当然,如果连续多次同类错误,就要终止任务并报告,不能让 Agent 无限重试。

def handle_error(error, context): if error.retryable and context.retry_count < 3: context.retry_count += 1 return Action.RETRY if error.has_fallback: return Action.FALLBACK return Action.REPORT_TO_MODEL

3.7 Observability:看不见的 Agent 没法调优

Observability 是最后一个子系统,也是最容易被省略的。很多人觉得 Agent 能跑就行,要什么可观测性。结果出了问题完全不知道哪里错了,只能靠 print 大法。

一个基本的 Observability 子系统要记录:每轮循环的输入输出、每次模型调用的耗时和 token 消耗、每次工具调用的参数和结果、每次错误的发生和处理。这些数据要结构化存储,方便后续查询和分析。我一般用 JSON 行格式写日志,每行一个事件,包含时间戳、事件类型、关键字段。

有了这些数据,你才能回答一些关键问题:任务平均需要多少轮、哪个工具最常失败、token 消耗主要花在哪里、哪些任务类型成功率低。这些问题的答案直接指导你优化 Harness。没有可观测性,优化就是盲人摸象。

观测指标采集方式用途
循环轮数Loop 计数器判断任务复杂度
模型调用耗时调用前后打点发现性能瓶颈
token 消耗API 返回统计成本控制
工具成功率结果统计工具质量评估
错误分布错误类型计数定位薄弱环节

4. 从零搭一个最小可用 Harness 的实操过程

4.1 环境准备与目录结构

我假设你用 Python 来搭,模型接入用任意兼容 OpenAI 接口的服务。目录结构我习惯这样组织:

harness/ loop.py # Agent Loop llm.py # LLM Integration context.py # Context Management tools/ registry.py # 工具注册表 runtime.py # Tool Runtime memory.py # Memory & State errors.py # Error Handling observability.py # Observability main.py # 入口

每个文件对应一个子系统,互相之间通过明确的接口调用。不要把所有东西塞一个文件里,后期改起来会疯。

4.2 第一步:把 LLM Integration 跑通

先别管其他,把模型调用跑通。写一个invoke函数,输入消息列表,输出模型响应。加上重试和超时。测试方法是:连续调用 100 次,看有没有失败。如果失败率超过 1%,说明重试策略不够。

# llm.py def invoke(messages, tools=None, timeout=60): payload = {"messages": messages} if tools: payload["tools"] = tools return invoke_with_retry(payload, timeout)

这一步的关键是把模型返回格式摸清楚。不同模型返回工具调用的字段名可能不一样,你要在这一层统一掉。我一般定义一个内部标准格式,比如{"type": "tool_call", "name": "...", "params": {...}},上层只认这个格式。

4.3 第二步:搭 Tool Runtime 和工具注册表

工具注册表用一个字典维护,每个工具包含名称、描述、参数 schema、执行函数。注册的时候要校验 schema 合法性,避免模型拿到错误的参数定义。

# tools/registry.py TOOLS = {} def register(name, description, params_schema, func): TOOLS[name] = { "name": name, "description": description, "parameters": params_schema, "func": func } def get_tool_schemas(): return [{"name": t["name"], "description": t["description"], "parameters": t["parameters"]} for t in TOOLS.values()]

先注册两三个简单工具测试,比如一个计算器、一个当前时间查询。工具描述要写清楚,这是模型选择工具的唯一依据。

4.4 第三步:写 Agent Loop 并串起上下文

Loop 的逻辑前面讲过,这里补充上下文组装的细节。每轮循环开始时,Context Management 要重新组装消息列表:系统提示词 + 历史摘要 + 最近对话 + 工具结果。组装完检查 token 预算,超了就触发压缩。

# loop.py def run(user_input, max_turns=20): state = init_state(user_input) for turn in range(max_turns): messages = context.build(state) response = llm.invoke(messages, tools=registry.get_tool_schemas()) action = parse_action(response) if action.is_final: return action.content result = tool_runtime.execute(action.name, action.params) context.append_tool_result(state, result) observability.log_turn(turn, action, result) return "达到最大轮数,任务未完成"

跑通这一步,你就有了一个能执行多步任务的 Agent。虽然简陋,但骨架在了。

4.5 第四步:补上错误处理和状态持久化

在 Loop 的每一步外面包一层错误处理。工具执行失败不中断,把错误作为结果回流。每轮结束把 State 存盘,重启能恢复。

# 在 loop 里 try: result = tool_runtime.execute(action.name, action.params) except Exception as e: result = errors.handle(e, state) state.save() # 每轮持久化

状态持久化用最简单的 JSON 文件就行,不用上数据库。关键是每轮都存,不要等任务结束才存,否则中途挂了就全丢了。

4.6 第五步:加上可观测性,开始调优

最后加上日志记录,然后拿几个真实任务跑一跑。看日志里的轮数、耗时、token 消耗、错误分布。我第一个 Agent 跑真实任务时,发现平均轮数 18 轮,其中 6 轮是重复调用同一个工具。查日志发现是工具返回结果格式不对,模型没看懂,反复重试。修了结果格式后,轮数降到 8 轮。

这就是可观测性的价值:没有数据,你根本不知道问题在哪。

5. 常见问题与排查技巧实录

5.1 模型反复调用同一个工具怎么办

这是最常见的问题。原因通常有三个:工具返回结果模型没看懂、工具描述有歧义、上下文里缺少“已经调用过”的记录。排查顺序是:先看工具返回结果是否结构化清晰,再看工具描述是否明确,最后检查上下文是否保留了调用历史。

解决方法:在工具结果里明确标注“这是第 N 次调用此工具”,模型看到重复计数会倾向于换策略。另外可以在系统提示词里加一句“避免重复调用相同参数的同一工具”。

5.2 上下文超限导致任务中断

上下文超限通常发生在长任务后期。排查方法是看日志里每轮的 token 消耗曲线,如果持续上升没有回落,说明压缩策略没生效。检查 Context Management 的压缩触发阈值是否设置合理,以及压缩后是否真的减少了 token。

我的经验是:压缩阈值设在上限的 70%,留 30% 缓冲。压缩时优先压缩工具返回结果,其次压缩早期对话,系统提示词和最近对话不动。

5.3 工具调用参数格式错误

模型生成的参数经常不符合 schema,比如该传数字传了字符串,该传数组传了单个值。Tool Runtime 要做参数校验和类型转换,能转的自动转,不能转的返回明确错误让模型重试。

问题现象可能原因排查方法解决方向
反复调用同一工具结果格式不清看工具返回结构规范化返回格式
上下文超限压缩未触发看 token 曲线调低压缩阈值
参数格式错误schema 不清晰看工具描述补充参数示例
任务中途卡死无超时控制看最后一条日志加工具超时
重启后状态丢失未持久化检查存盘逻辑每轮存 State

5.4 Agent 跑着跑着就“忘了”目标

这是上下文压缩的副作用。压缩时把早期对话摘要了,但摘要没保留任务目标。解决方法是在 State 里单独存一份“任务目标”,每轮组装上下文时强制放在最前面,不参与压缩。这样无论压缩多少次,目标始终在。

注意:任务目标要原样保留,不要摘要。摘要会丢失细节,导致模型对目标的理解逐渐偏移。我踩过这个坑,一个“生成 10 条数据”的任务,压缩后模型理解成“生成一些数据”,最后只生成了 3 条。

5.5 工具执行有副作用怎么保证安全

有副作用的工具(写文件、发请求、改数据)要特别小心。我的做法是:所有副作用工具默认走“预演模式”,只返回“将要执行什么”,等模型确认后再实际执行。或者在 Harness 层面加一个人工确认环节,副作用操作需要用户点确认。

对于自动化场景,至少要加幂等性保护:同一个操作重复执行不产生额外副作用。比如写文件用覆盖而不是追加,发请求带唯一 ID 去重。

6. 关于 Harness 工程化的一些个人体会

搭 Harness 这件事,我最大的体会是:难点不在写代码,在于定义清楚每个子系统的边界。代码本身都不复杂,Loop 就是循环,Tool Runtime 就是调用函数,但边界不清就会互相污染。我早期版本把上下文压缩逻辑写在 Loop 里,后来想换压缩策略,发现要改 Loop 的核心逻辑,风险极大。拆成独立子系统后,换压缩策略只改一个文件,测试也简单。

另一个体会是:不要追求一次设计完美。Harness 是长出来的,不是设计出来的。先跑通最小闭环,然后根据实际遇到的问题逐个补子系统。我第一版 Harness 只有 Loop 和 Tool Runtime,跑了一周发现上下文爆炸,才加 Context Management;又跑了一周发现错误处理太糙,才加 Error Handling。每个子系统都是被真实问题逼出来的,这样加进来的子系统才真正有用。

最后分享一个小技巧:给 Harness 写一套回归测试用例。准备 10 到 20 个典型任务,每次改完 Harness 跑一遍,看成功率和平均轮数有没有退化。这套测试是我改 Harness 时最大的底气,没有它我根本不敢动核心逻辑。测试用例不用复杂,就是输入加预期结果,但覆盖的场景要全:简单问答、多步工具调用、工具失败恢复、长任务上下文压缩。跑一遍几分钟,能省下大量调试时间。

这套 7 子系统结构我用了大半年,中间调整过几次边界,但整体框架没变。它不算完美,但足够支撑一个能真正干活的 Agent。如果你正在搭自己的 Harness,希望这些拆解能帮你少走点弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询