☰
AI Agent Harness 七个子系统:从 LLM 集成到可观测性的工程实践
2026/10/1 10:43:48 网站建设 项目流程

1. 先搞清楚 Harness 到底指什么

很多人第一次听到 AI Agent 的 Harness,脑子里浮现的是测试框架或者某种脚手架工具。这个理解不算错,但太窄了。在 AI Agent 的语境里,Harness 指的是包裹在 LLM 外面、让模型真正能"干活"的那一整套运行时基础设施。模型本身只会输出 token,它不会读文件、不会发请求、不会记住上一轮对话、更不会在失败后重试。把这些能力补齐的那层东西,就是 Harness。

你可以把 LLM 想象成一个极其聪明但被关在隔音玻璃房里的专家。他能回答问题,但看不见外面的世界,也伸不出手。Harness 就是给这位专家配的电话线、机械臂、记事本和助手团队。没有 Harness,再强的模型也只是一个聊天框;有了 Harness,它才能变成能自主完成任务的 Agent。

我最初接触这个概念时也走过弯路,以为接个 API、写个 while 循环就算搭好 Agent 了。结果一上真实任务就崩:工具调用格式解析失败、上下文超长被截断、循环跑飞停不下来、并发一上来就乱序。后来才明白,Agent 的工程质量几乎全部落在 Harness 上,而不是模型本身。模型是买来的,Harness 才是你自己要造的东西。

那 Harness 到底由哪些部分组成?拆到最细没必要,但归纳到可落地的粒度,核心就是七个子系统。下面我按"从请求进来到结果出去"的实际数据流,把这七块逐一拆开讲,每一块都配上我踩过的坑和可复现的做法。

2. 七个子系统的整体架构与数据流

2.1 为什么是七块,而不是三块或十块

先说拆分的逻辑。一个 Agent 跑一次任务,本质上要回答七个问题:模型从哪来(LLM Integration)、这一轮该带什么上下文(Context Management)、模型说要调工具怎么执行(Tool Execution)、多轮怎么串起来(Agent Loop)、状态存哪(Memory & State)、同时来一堆任务怎么办(Concurrency & Scheduling)、出错了怎么发现和恢复(Observability & Recovery)。这七个问题各自独立、职责清晰,任何一块缺失都会导致系统在真实场景下不可用。

拆成三块(模型、工具、循环)会漏掉状态、并发和可观测性,这三块恰恰是 demo 和生产的最大分水岭。拆成十块以上又会过度设计,小团队根本维护不过来。七块是我实践下来既能覆盖生产需求、又不至于让个人开发者望而却步的平衡点。

2.2 一次完整请求的数据流

把七块串起来看,一次 Agent 任务的流转是这样的:

  1. 用户输入进入Agent Loop,Loop 初始化本轮状态
  2. Loop 向Context Management要"这一轮该发给模型的完整 prompt"
  3. Context 从Memory & State取出历史、从工具注册表取出可用工具描述,拼装成消息
  4. 拼好的请求交给LLM Integration,由它处理鉴权、重试、流式解析
  5. 模型返回内容,LLM Integration解析出是"普通回复"还是"工具调用"
  6. 如果是工具调用,交给Tool Execution执行,结果写回Memory & State
  7. Loop 判断是否继续下一轮,Concurrency & Scheduling决定这个任务和其他任务怎么排队
  8. 全程Observability & Recovery记录每一步,出错时触发重试或降级

这个数据流是理解后面所有细节的主线。你会发现,Loop 是骨架,其他六块是挂在骨架上的器官。下面逐块拆。

3. 子系统一:LLM Integration(模型接入层)

3.1 它到底要解决什么问题

很多人觉得"调模型"就是发个 HTTP 请求,能有多难。真做过就知道,模型接入层要处理的破事一大堆:不同厂商的 API 格式不一样、流式和非流式返回结构不同、工具调用的 JSON 可能不合法、限流和超时随时发生、token 计费要统计、多模型要能热切换。这一层做不好,上层逻辑写得再漂亮也是空中楼阁。

我的做法是在这一层做厚,把脏活全吃掉,让上层只面对一个干净的接口。上层调用时只说"给我一个回复,可能带工具调用",至于底层是哪个模型、怎么重试、怎么解析,上层完全不关心。

3.2 统一抽象与多模型适配

核心是定义一个统一的请求/响应结构。请求侧统一成 messages 数组加 tools 列表,响应侧统一成 content 加 tool_calls。不同厂商的差异在适配器里消化掉。

class LLMResponse: def __init__(self, content, tool_calls, usage, finish_reason): self.content = content self.tool_calls = tool_calls # 统一成 [{id, name, arguments}] self.usage = usage self.finish_reason = finish_reason class BaseAdapter: def chat(self, messages, tools, stream=False): raise NotImplementedError class OpenAICompatAdapter(BaseAdapter): def chat(self, messages, tools, stream=False): # 处理 OpenAI 兼容格式,含流式增量拼接 ...

这样设计的好处是,换模型只改适配器,Agent Loop 一行不动。我实测过从一家模型切到另一家,只要适配器写对,上层逻辑零改动。

3.3 流式解析与工具调用拼接

流式返回是坑最多的地方。模型返回工具调用时,arguments 是分片吐出来的,你必须自己拼接完整再解析 JSON。我见过太多人直接对每个 chunk 做 json.loads,结果必然报错。

正确做法是维护一个按 index 索引的缓冲区,把每个 delta 的 arguments 片段累加,等 finish_reason 变成 tool_calls 时再统一解析。这里还要处理一个恶心情况:模型偶尔吐出不合法 JSON,比如多一个逗号、少一个引号。我的经验是加一层容错解析,失败时尝试修复常见错误,再失败就带着错误信息让模型重试一次。

注意:流式场景下不要在每个 chunk 都触发 UI 更新和计费,那样既卡又乱。按 token 累积到一定量或按时间窗口批量刷新,体验和性能都更好。

3.4 重试、限流与成本控制

重试要区分错误类型。网络超时、5xx 可以指数退避重试;4xx 里的鉴权失败、参数错误重试没意义,直接抛出。限流(429)要读响应头里的重试时间,别傻等固定间隔。

成本控制这块,我习惯在适配器里记录每次调用的 token 数和估算费用,按任务维度汇总。这样跑一段时间就能看出哪个环节最烧钱,往往是上下文太长或者循环次数太多。没有成本可观测性的 Agent,跑着跑着账单就失控了。

4. 子系统二:Context Management(上下文管理)

4.1 上下文是 Agent 最稀缺的资源

模型的上下文窗口再大也是有限的,而 Agent 跑多轮任务时,历史消息、工具返回结果、系统提示会迅速膨胀。上下文管理要解决的核心矛盾是:既要给模型足够的信息做决策,又不能让 prompt 无限增长导致超限或成本爆炸。

我见过最典型的翻车场景:一个 Agent 处理长文档,把整篇文档塞进上下文,第一轮还行,跑到第五轮直接超限报错。这不是模型的问题,是上下文管理没做好。

4.2 分层组织与优先级

我的做法是把上下文分成几层,按优先级动态裁剪:

层级内容是否可裁剪裁剪策略
系统层角色设定、核心规则不可裁剪永远保留
任务层当前任务目标、约束尽量保留压缩为摘要
工具层可用工具描述可裁剪按需注入
历史层对话与工具结果可裁剪摘要或滑窗
即时层最近一轮交互不可裁剪永远保留

系统层和即时层是硬约束,中间三层按 token 预算动态调整。这样即使任务跑很久,核心信息也不会丢。

4.3 摘要压缩与滑窗的取舍

历史太长时有两个选择:滑窗(只保留最近 N 轮)和摘要(把旧内容压缩成一段话)。滑窗简单但会丢信息,摘要保留信息但可能失真。

我的经验是两者结合:最近几轮用原文保留细节,更早的内容用摘要压缩。摘要的 prompt 要明确要求"保留关键决策、已完成的步骤、未解决的问题",而不是泛泛地总结。实测下来,这种混合策略在长任务上的表现明显好于纯滑窗。

提示:摘要本身也是一次 LLM 调用,有成本和延迟。不要每轮都摘要,可以设定阈值,比如历史超过窗口的 60% 才触发一次压缩。

4.4 工具描述的按需注入

工具多了以后,把所有工具描述都塞进 prompt 会占用大量 token,还会干扰模型选择。我的做法是按任务类型动态注入相关工具。比如任务是"查数据",就只注入数据库查询类工具,不注入发邮件、写文件的工具。这样既省 token,又提高工具选择的准确率。

5. 子系统三:Tool Execution(工具执行层)

5.1 工具是 Agent 的手脚

模型再聪明,不能执行就等于零。工具执行层负责把模型输出的"我要调用某工具、参数是这些"变成真实的动作,再把结果返回给模型。这一层的关键词是安全、可靠、可观测。

5.2 工具注册与 Schema 定义

每个工具要有清晰的名称、描述和参数 schema。描述写得好不好,直接决定模型会不会正确使用。我踩过的坑是描述写得太简略,模型经常传错参数类型。后来我把描述写得像给新人看的文档,包含用途、参数含义、示例,工具调用准确率明显提升。

tools = [ { "name": "query_database", "description": "根据 SQL 查询数据库并返回结果。仅支持 SELECT 语句。", "parameters": { "type": "object", "properties": { "sql": {"type": "string", "description": "标准 SQL 查询语句"}, "limit": {"type": "integer", "description": "返回行数上限,默认 100"} }, "required": ["sql"] } } ]

5.3 参数校验与沙箱隔离

模型给的参数永远不要直接信任。必须做类型校验、范围校验、白名单校验。执行 SQL 要限制只能 SELECT,执行 shell 要限制命令白名单,访问文件要限制目录范围。

沙箱隔离是底线。我习惯把工具执行放在受限环境里,超时强制中断,资源用量设上限。曾经有个 Agent 因为工具里写了个死循环,把整个进程拖死,从那以后所有工具执行都加了超时。

5.4 执行结果的处理与回填

工具返回的结果可能很长,直接塞回上下文会撑爆窗口。我的做法是结果先截断或摘要,再回填。比如查询返回一千行,只把前若干行和总行数给模型,需要更多再让它分页查。

结果回填的格式也要统一,明确标注是哪个工具、调用是否成功、返回了什么。这样模型下一轮才能正确理解。

注意:工具执行失败时,不要把原始堆栈直接给模型,那会污染上下文。转成人类可读的错误描述,比如"查询失败:字段名不存在",让模型有机会修正。

6. 子系统四:Agent Loop(智能体主循环)

6.1 Loop 是整个 Harness 的心脏

前面三块都是为 Loop 服务的。Loop 负责编排:拿上下文、调模型、判断要不要执行工具、执行完再回到模型、直到任务完成或达到终止条件。这个循环写得好不好,决定了 Agent 是"能干活"还是"瞎折腾"。

6.2 循环的终止条件设计

最常见的 bug 是循环停不下来。模型一直调工具,或者一直说"我再想想",跑几十轮还在原地。必须设计多重终止条件:

  • 模型返回了最终答案(没有工具调用)
  • 达到最大轮数上限(比如 15 轮)
  • 连续 N 轮没有实质性进展
  • 总 token 或总耗时超预算
  • 检测到重复的工具调用模式

我一般把最大轮数设成 10 到 15,配合"无进展检测"。无进展的判定可以看连续几轮的工具调用是否高度相似,或者模型输出是否在重复。

6.3 单轮循环的完整实现

def run_agent(task, max_turns=15): state = init_state(task) for turn in range(max_turns): context = build_context(state) response = llm.chat(context, tools=registry.tools) if not response.tool_calls: return response.content # 任务完成 for call in response.tool_calls: result = execute_tool(call, sandbox=True, timeout=30) state.add_tool_result(call.id, result) if no_progress(state): return "任务未能推进,已停止" return "达到最大轮数,已停止"

这段代码看着简单,但每一行背后都有讲究。build_context 要做上下文裁剪,execute_tool 要做校验和隔离,no_progress 要做模式检测。

6.4 循环中的状态传递

每一轮之间要传递什么状态?我的经验是至少包含:任务目标、已完成的步骤、当前待解决的问题、工具调用历史。这些状态既影响上下文构建,也影响终止判断。状态设计得清晰,调试时一眼就能看出 Agent 卡在哪。

7. 子系统五:Memory & State(记忆与状态)

7.1 短期记忆与长期记忆的分工

短期记忆是当前任务内的对话和工具结果,任务结束就丢弃。长期记忆是跨任务的知识,比如用户偏好、历史结论、领域知识。两者存储方式和生命周期完全不同,不能混在一起。

短期记忆我一般放内存或 Redis,读写快、过期自动清理。长期记忆放向量库或关系库,需要检索时再取。

7.2 状态持久化与断点续跑

Agent 任务可能跑很久,中途进程挂了怎么办?状态持久化就是答案。每一轮结束把状态存下来,重启后能从断点继续。这在长任务场景下是刚需。

我踩过的坑是状态序列化时把不可序列化的对象也存了,恢复时报错。后来规定状态里只放基础类型和明确可序列化的结构,问题就没了。

7.3 记忆检索的时机与策略

长期记忆不是每轮都检索,那样又慢又费 token。我的做法是在任务开始时检索一次相关背景,任务过程中如果模型明确需要历史信息,再触发检索。检索结果也要做相关性过滤,别把不相关的记忆塞进去干扰模型。

8. 子系统六:Concurrency & Scheduling(并发与调度)

8.1 并发是 Agent 从玩具到生产的分水岭

单用户单任务时,怎么写都行。一旦多个用户同时用,或者一个任务要并行处理多个子任务,并发问题就全冒出来了。上下文串了、状态覆盖了、限流打爆了,这些都是并发没做好。

8.2 任务队列与隔离

我的做法是每个任务有独立的会话 ID 和状态空间,任务之间完全隔离。任务进队列,由 worker 池消费。这样既能控制并发数,又能保证隔离性。

class TaskScheduler: def __init__(self, max_workers=10): self.queue = asyncio.Queue() self.semaphore = asyncio.Semaphore(max_workers) async def submit(self, task): await self.queue.put(task) async def worker(self): while True: task = await self.queue.get() async with self.semaphore: await self.run_task(task)

信号量控制并发上限,避免把下游模型 API 打爆。队列保证任务不丢。

8.3 限流与背压

下游模型有 QPS 限制,工具执行有资源限制,这些都要在调度层做背压。当队列积压超过阈值,要么拒绝新任务,要么降级处理。我一般会监控队列长度和任务等待时间,超过阈值就告警。

8.4 并行工具调用的处理

模型一轮可能返回多个工具调用,这些调用如果互不依赖,可以并行执行省时间。但要注意:并行执行的结果回填顺序要和调用顺序对应,否则模型会混乱。我的做法是并行执行、按原顺序回填。

提示:并行工具调用要设总超时,不能因为一个慢工具拖垮整轮。用 asyncio.gather 配合 timeout,超时的工具返回超时错误,让模型决定是否重试。

9. 子系统七:Observability & Recovery(可观测与恢复)

9.1 看不见的 Agent 等于失控

Agent 跑起来是个黑盒,如果不记录每一步,出问题根本没法查。可观测性要覆盖:每轮的输入输出、工具调用及结果、token 消耗、耗时、错误。这些数据既是调试依据,也是优化依据。

9.2 结构化日志与链路追踪

我习惯给每个任务分配 trace_id,所有日志带上这个 ID,这样能完整还原一个任务的全过程。日志要结构化,方便检索和统计。

logger.info("tool_call", extra={ "trace_id": state.trace_id, "turn": turn, "tool": call.name, "args": call.arguments, "duration_ms": elapsed, "success": result.success })

9.3 错误分类与恢复策略

错误要分类处理:模型调用失败可重试,工具执行失败可让模型换方案,上下文超限要触发压缩,循环卡死要强制终止。每类错误对应不同的恢复动作,不能一刀切。

错误类型典型场景恢复策略
模型超时/限流API 不稳定指数退避重试
工具参数错误模型传错参数返回错误让模型修正
上下文超限历史过长触发摘要压缩
循环无进展模型原地打转强制终止并报告
状态损坏序列化异常从上一个检查点恢复

9.4 人工介入与降级

有些情况 Agent 自己解决不了,需要人工介入。比如连续失败、涉及敏感操作、置信度低。这时候要能暂停任务、通知人工、支持人工修正后继续。降级策略也要有,比如模型不可用时切换到备用模型或返回兜底回复。

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

10.1 工具调用格式解析失败

这是最高频的问题。模型返回的 arguments 不是合法 JSON,或者流式拼接时漏了片段。排查思路:先打印原始返回,确认是模型问题还是解析问题。如果是模型问题,在 prompt 里强调"必须返回合法 JSON",或者用支持结构化输出的模型。如果是解析问题,检查流式拼接逻辑,确保按 index 正确累加。

10.2 循环停不下来

先看日志里每轮的工具调用,判断是模型在重复调用还是真的在推进。如果是重复,检查工具返回结果是否让模型误以为没成功。如果是推进但慢,调大最大轮数或优化上下文。我遇到过一次是工具返回格式不清晰,模型以为失败了反复重试,改清楚返回格式就好了。

10.3 上下文超限

监控每轮的 token 数,找出增长最快的部分。通常是工具返回结果太长。解决方法是结果截断加摘要,或者分页返回。另外检查系统提示是不是写得太长,有些人的系统提示能写几千字,纯属浪费。

10.4 并发下状态串扰

症状是 A 用户的任务里出现了 B 用户的数据。排查方向:检查状态是否用了全局变量,检查会话 ID 是否正确隔离,检查异步任务是否共享了可变对象。我踩过一次是用了类变量存状态,多任务一跑就串,改成实例变量就好了。

10.5 成本失控

按任务统计 token 消耗,找出最烧钱的环节。常见原因是上下文太长、循环轮数太多、工具返回结果太大。针对性优化:压缩上下文、设轮数上限、截断工具结果。我一般会设一个单任务成本上限,超了就终止。

提示:排查问题时,先把 trace 日志拉出来完整看一遍,90% 的问题看日志就能定位,别急着改代码瞎猜。

11. 从零搭一个最小可用 Harness 的实操顺序

如果你现在就要动手,我建议按这个顺序来,每一步都能跑通再进下一步:

  1. 先写 LLM Integration:能调通一个模型,能解析普通回复和工具调用,能处理流式。这一步跑通,你就有了最基础的对话能力。
  2. 加 Tool Execution:注册一两个简单工具,能执行、能回填结果。这一步跑通,模型就能"动手"了。
  3. 写 Agent Loop:把前两步串起来,加上终止条件。这一步跑通,一个能完成简单任务的 Agent 就成型了。
  4. 补 Context Management:加上上下文裁剪和摘要,让长任务不超限。
  5. 加 Memory & State:状态持久化,支持断点续跑。
  6. 上 Concurrency:任务队列和隔离,支持多任务。
  7. 最后补 Observability:日志、追踪、错误恢复。

这个顺序的好处是每一步都有可验证的产出,不会一开始就陷入架构泥潭。我见过太多人一上来就想把七块全搭好,结果哪块都没跑通,最后放弃。

12. 几个容易被忽略的工程细节

12.1 工具描述的质量决定一切

模型选不选对工具、传不传对参数,八成取决于工具描述写得好不好。把描述当成给新人的文档来写,包含用途、参数、示例、边界情况。这一块的投入回报率极高。

12.2 终止条件要冗余

不要只靠一个终止条件。最大轮数、无进展检测、超时、超预算,多重保险。我吃过亏,只设了最大轮数,结果模型每轮都调工具但没进展,白白烧了十几轮的钱。

12.3 状态设计要面向调试

状态里存什么,直接决定你调试时能看到什么。我习惯把每轮的决策依据、工具调用、结果都存进状态,出问题时能完整还原。多存一点不亏,调试时省的时间远超存储成本。

12.4 错误信息要给人看也给模型看

工具执行失败时,返回给模型的错误信息要清晰可操作,比如"字段 user_id 不存在,可用字段有 id、name、email",这样模型下一轮就能修正。含糊的"执行失败"只会让模型瞎猜。

12.5 别过早优化

七个子系统不是一开始都要做到生产级。先让核心链路跑通,再逐步加固。我见过有人花两周设计完美的并发架构,结果单任务都还没跑通。先能干活,再谈干得好。

这套 Harness 的七个子系统,说到底就是把"让模型真正干活"这件事拆解成可管理、可迭代的模块。模型会不断更新换代,但 Harness 的这套骨架是稳定的。把骨架搭好,换什么模型都能快速接上。我自己从最初一个 while 循环的玩具,到现在能扛住多任务并发的系统,中间踩的坑基本都在这七块里。希望这些经验能帮你少走点弯路,把精力花在真正创造价值的地方。

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

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

立即咨询