ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Agent Harness层:构建可观测、可回放、可插拔的运行时

Agent Harness层:构建可观测、可回放、可插拔的运行时

1. 为什么我要折腾一个 Agent 的 Harness 层

先说个场景。某天凌晨两点,我负责的 Agent 任务又跑挂了。爬起来打开日志,看到的是一堆散落在不同文件里的记录:模型调用的请求响应、工具执行的入参出参、状态机的迁移记录……彼此之间没有关联,时间戳对得上但因果链全断。我花了三个小时才拼出事情经过:原来是工具 A 返回了一个异常格式,Agent 错误地选择了重试,重试时又把同一个脏数据传给了工具 B,最后在工具 B 那边炸了。

问题不在 Agent 本身,而在运行环境。模型的推理能力再强,没有一套能把"每一步发生了什么、为什么这么做、做完之后世界变成什么样"完整记录下来的底座,调试就是灾难。那一刻我下定决心,不能再用拼凑的脚本把 Agent 串起来了,必须给 Agent 套一个真正的Harness——一个可控、可观测、可扩展的运行时外壳。

这个项目后来被我叫作 DeepSeek Harness。核心就两件事:一是把 Agent 的所有能力(工具、记忆、策略)全部插件化,主流程不写死任何具体实现;二是把会话过程完整落盘成可回放的事件日志,任何一次运行都能像倒带一样重看。这篇文章就把这两块工程化设计从头到尾拆一遍,包括我踩过的坑和最终的取舍方案,适合已经在用各类 Agent 框架、但想更进一步掌控运行时的开发者参考。

很多人问:市面上框架那么多,为什么还要自己造?我的回答是:框架解决的是"怎么把 Agent 跑起来",Harness 解决的是"跑起来之后怎么控制它"。两者是不同层面的东西。用过一段时间之后你会发现,模型能力的天花板往往不是模型本身,而是你手上那套运维工具的粒度——日志够不够细、能不能回放、插件能不能热插拔、出了故障能不能快速定位。这些才是一个 Agent 系统从 Demo 走向工程化的分水岭。

2. 从 Demo 到工程化,中间差了一整个可观测层

2.1 裸跑 Agent 时你根本没意识到的问题

大多数人在本地跑一个 Agent Demo 时,流程是:写一个 while 循环,调模型,拿结果,判断要不要调工具,再调模型……跑通了就觉得很顺。但一旦进入真实业务,你会发现几个藏得很深的问题。

第一个问题是"黑盒"。模型返回了一大段 JSON,里面有 thoughts、tool_calls、final_answer。你看到了结果,但看不到模型内部的决策依据——它为什么选择先查数据库而不是先调搜索?这个"不可问"的状态在单轮 Demo 里无所谓,但在多轮复杂任务里,你会被迫接受"模型说什么就是什么",根本没办法干预和校准。

第二个问题是"状态散落"。为了把任务做完,你会维护一堆上下文变量:当前计划、已完成步骤、待执行队列、工具间传递的临时数据。这些变量散落在代码各处,一旦流程中断(超时、报错、模型输出格式异常),恢复现场基本靠运气。

第三个问题是"无法重放"。线上跑出一个诡异结果,你想让模型再跑一遍,但上下文变了、工具返回变了、模型采样也有随机性——同样输入、同样代码,结果对不上。于是你没法定位到底是哪一步引入的问题。

这三个问题归到底都是同一个根因:Agent 运行时缺少一个统一的数据面和控制面。模型调用是数据面的一部分,工具执行是数据面的一部分,状态迁移也是数据面的一部分,但它们没有一个统一的采集通道和统一的控制入口。

2.2 Harness 层到底管什么

所以 Harness 层管的东西不是"Agent 的业务逻辑",而是 Agent 运行时的六大横切关注点:

关注点裸跑时的状态Harness 的目标
模型调用直接调 SDK,参数散落统一入口,记录每次请求与响应
工具执行函数指针到处传插件化注册,带鉴权和限流
会话状态全局变量或 context 对象结构化状态包,可持久化
日志记录print 或 logger 随手打结构化事件流,可回放
错误处理try-except 层层包统一错误边界,故障可诊断
配置管理环境变量 + 硬编码分级配置,插件独立配置块

你可能会说,这些用现成的框架也有啊。没错,很多 Agent 框架都有 session、trace、tool 列表这些东西。但深度用下来你会发现:框架给的是"默认能跑"的最小实现,而工程化需要的是"任何一环出问题你都能把它抠出来查清楚"的深度控制。

具体到我这个项目,Harness 的定位是不取代任何 Agent 框架,它是在框架下面的一层运行时基础设施。你可以把它理解成:Agent 是发动机,Harness 是底盘。发动机决定车能跑多快,底盘决定你能不能安全可控地让它跑。

2.3 我选择的两个核心抽象

在动手写代码之前,我先定了两个核心抽象,后面的所有设计都围绕它们展开。

第一个抽象是Step。一次 Agent 运行被拆成若干 Step,每个 Step 要么是一次模型调用,要么是一次工具执行,要么是一次状态迁移。每个 Step 都有全局唯一的序列号、入参、出参、耗时、状态。所有 Step 串起来就是一次完整的会话事件流。

第二个抽象是ReplayableSession。一次会话是一个持久化对象,里面装了三样东西:会话头(模型配置、插件版本、全局参数)、事件索引(Step 序列)、状态快照(关键节点上的完整上下文)。有了这三个东西,回放就变成了纯粹的确定性计算。

这两个抽象看起来简单,但它们把前面提到的三大问题全解决了:Step 解决黑盒问题(每一步都可见),ReplayableSession 解决状态散落问题(上下文可恢复),事件流 + 快照解决无法重放问题。后面两节就详细展开这两块是怎么落地的。

3. 全插件化设计:把 Agent 的每项能力变成可插拔零件

3.1 插件协议:随便写个函数不叫插件化

先说一个很多人理解上的偏差。插件化不是"写个函数注册进来就行",而是运行时与实现之间有一条稳定的协议边界。这条边界决定了哪些事情由框架管、哪些事情由插件管,双方如何通信,出了问题谁负责。

我在 DeepSeek Harness 里定义的插件协议包含四个部分:

  • 元信息声明:插件名称、版本、依赖的框架版本、作者描述、权限声明(需要访问哪些资源、调哪些外部系统)。
  • 生命周期钩子:on_load(加载时初始化)、on_ready(就绪检查)、on_shutdown(卸载时清理)。这三个钩子让框架能管理插件的完整生命周期,而不是"加载了就听天由命"。
  • 执行入口:一个统一的invoke(payload, context) -> result接口。所有工具类插件都实现这个接口,框架用同一个签名调度,不区分你是查数据库、调 HTTP API 还是跑一段本地代码。
  • 输出约束:插件必须返回结构化结果(我定义了一套统一的 Result 包装,包含 code、data、error、trace_id),不允许丢一个裸字符串或裸对象出来。

这个协议一旦定下来,主流程就可以非常干净。伪代码大概长这样:

class PluginBase: name: str version: str permissions: list[str] async def on_load(self, config: dict) -> None: ... async def on_ready(self) -> bool: ... async def invoke(self, payload: dict, ctx: StepContext) -> PluginResult: ... async def on_shutdown(self) -> None: ...

看到没有,框架完全不关心插件内部是怎么实现的。你爱用同步还是异步,内部调 SQLAlchemy 还是 requests,都是插件自己的事。框架只认这个接口。这就是插拔能力的根基——协议稳定,实现自由。

3.2 注册中心:你总得有个"户口本"

只有协议还不够,还得有一个所有插件都能被找到、被校验、被依赖解析的地方。这就是插件注册中心。

我实现了一个三层结构的注册中心:

  • 静态声明层:启动时从配置目录扫描所有插件的 manifest 文件(我用的 YAML),建立全局插件清单。
  • 动态加载层:按照依赖关系排序后逐个执行on_load,实例化插件对象,注入共享的上下文(日志句柄、配置对象、外部客户端)。
  • 健康检查层:定期调用on_ready,如果返回 False 或抛异常,把插件标记为不可用,路由时自动跳过。

这个注册中心解决了一个很实际的问题:插件之间的依赖关系。举个真实例子,我写了一个数据库查询插件,又写了一个数据脱敏插件,脱敏插件依赖查询插件返回的元信息。如果注册中心不做依赖解析,加载顺序错了,脱敏插件初始化时拿不到查询插件,直接跑崩。

我的做法是在 manifest 里声明depends_on,注册中心做一次拓扑排序。这个思路直接移植自微服务里的依赖治理,在 Agent 插件系统里同样适用:

# manifest.yaml name:>
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进