大多数 Agent 团队不会自己构建一个 harness。他们会直接采用现成的。LangChain、LangGraph、OpenAI Agents SDK、Anthropic SDK、CrewAI、AutoGen——循环、工具、记忆、编排,统统作为一个整体决策从货架上拿下来。这个 harness 就是你 import 的一个框架。如果框架内部有什么东西不适用,你就 fork 它、对抗它,或者绕过它。

我认为这种形态是错误的,这也是为什么每个长期运行的 Agent 团队最终都会从头重写他们的 harness。Harness 并不是一个单一的东西。它是十到十二个不同组件的捆绑,因为周围生态没有提供组合它们的方式。Pi agent 包走在正确的轨道上,但它们仍然停留在“添加另一个服务并与其他所有服务集成”的范式里。而 iii 引擎将所有 worker 一视同仁,完全消除了集成逻辑。Provider 路由器、凭证保险库、策略引擎、审批网关、模型目录、会话存储、预算追踪器、调用后钩子分发器、以及持久化循环——这些都是独立的关注点。它们都可以与你的队列、HTTP/API 服务器、流式传输、甚至浏览器 worker 互操作。一个将它们作为整体打包的框架,是在向你兜售一个你本不必做出的取舍。
iii 背后的赌注是:它们不应该是一个整体。应该有一组运行在共享引擎上的 worker,每个都可替换,每个都独立版本化,每个都通过一个单一原语连接:一个触发器(iii.trigger()),其他所有 worker 也使用它。Harness 变成了一组可安装的 worker,而“构建你自己的”不再意味着“fork 一个框架”,而是“替换几个 worker”。
这篇文章将带你了解这实际上是什么样子。今天驱动 iii agent 循环的完整堆栈,为什么每一层都是它自己的 worker,以及如何替换其中任何一个。
Agent Harness 必须完成的 15 项工作
如果你把生产环境中的 Agent harness 拆解到它的基本职责,你会得到一份大致如下所示的清单:
- 接受来自客户端的 turn 请求并持久化
- 为被调用的模型 provider 解析凭证
- 查询所选模型实际能做什么(视觉、工具、流式传输、上下文窗口)
- 驱动每个 turn 的状态机:准备、流式传输助手回复、运行工具、引导、拆除
- 加载并服务技能体,描述每个函数的请求格式、错误码和使用说明
- 组装系统提示词:模式段落、身份序言、工作目录、默认技能附录
- 随着模型生成结果,将 token 流式传回客户端
- 在运行之前,根据策略检查每个工具调用(其实就是函数)
- 暂停需要人工决策的工具调用,并将答复路由回正确的 turn
- 根据工作区或 Agent 预算跟踪 LLM 花费
- 在工具调用前后运行钩子(日志记录、编辑、自定义副作用)
- 将会话持久化为分支树,以便支持分支和恢复
- 当上下文窗口填满时压缩会话历史
- 发出事件流,供 UI 订阅
- 我看到的每个 Agent 公司都在缺失的部分:在每一步都携带一个 OpenTelemetry 追踪,以便调试
每个严肃的 Agent harness 都涵盖其中大部分功能。昂贵的框架会全部实现。廉价的框架会偷工减料,等到生产环境中再重写这些部分。这些框架将它们捆绑成一个整体,并为每个组件发布一个版本。最后一部分正是让你付出代价的地方,因为一年后,你会发现你想要的策略引擎并不是框架自带的那个,而替换它意味着替换整个 harness。
iii 的 harness 将上述十三项工作中的每一项都作为独立 worker 发布在 workers.iii.dev 注册表上。每个 worker 使用相同的 WebSocket 协议。每个都在同一个引擎总线上注册函数和触发器。每个都可以通过 iii worker add 添加、替换,并且可以用任何拥有 SDK 的语言编写。
按 Worker 划分的堆栈
以下是来自 iii-hq/workers 单体仓库的实际生产堆栈,每个 worker 的工作用一行概括。整个捆绑包在 github.com/iii-hq/workers/harness 发布:

十一个 worker。一个引擎。每一个都有发布版本。每一个都可以作为独立进程运行(开发时使用 pnpm dev:<worker>,发布二进制文件时使用 iii worker add <specific-worker>),或者作为组合入口点的一部分一起启动。
这一点之所以重要:上表中的每一个框,都是别人可以给你一个不同 worker、而你保留其余部分的地方。不喜欢静态模型目录?插入一个 worker 注册 models::list 并从实时 API 读取。不喜欢基于文件的凭证?插入一个 worker 注册 auth::get_token 并从密钥管理器读取。想要一个不同的 turn 状态机来处理不同的分支工作流?替换 turn-orchestrator,所有依赖项都通过同一个总线调用 run::start 并读取 turn_state,因此堆栈的其他部分保持不变。
循环实际如何运行
一个 turn 的形状如下所示,按照它们触发的顺序遍历各个 worker。
浏览器/CLI/聊天通过 harness::trigger 发送一个 turn,包含 {session_id, message_id, payload}。Harness 元 worker 将 payload 转发给 run::start。这个跳跃的存在是为了让 OpenTelemetry span 包装器可以将 session 和 message ID 作为 baggage 注入,并传播到堆栈中每个 worker 的每个嵌套 iii.trigger 调用。另一端的追踪树是一个连通图。
run::start 到达 turn-orchestrator。它持久化运行请求,在 iii 状态中为 session/<sid>/turn_state 初始化 TurnStateRecord,并立即返回。实际工作发生在持久化的每状态机内部,由发布到 turn-step FIFO 的事件唤醒。
两个终端状态是 stopped(通过 finishSession() 干净退出)和 failed(意外的 handler throw 路由到这里,确认队列以避免重试,并发出 message_complete{stop_reason:'error'} 加上 agent_end,以便 UI 显示原因)。拆除是在任何 turn 结束路径上调用的内联 finishSession() 端口,而不是单独入队的步骤。
provisioning 做三件事。如果运行需要隔离执行,它会启动一个 iii-sandbox 微虚拟机。它会为 system_default_skills 中的每个命名空间(默认 ["iii://iii-directory/index"])调用 directory::skills::download,以便 iii-directory 预先缓存运行开始时使用的技能体。它还分三层组装系统提示词:从 run_request.mode(plan、ask 或 agent)中选择的模式段落;iii 身份序言,教导模型 agent_trigger 约定和 directory::skills::get 按需发现模式;以及 Agent 启动时使用的默认技能索引附录。调用者可以通过在 run::start 中传递 system_prompt 来覆盖整个提示词;否则由编排器构建。函数模式来自实时引擎目录。
assistant_streaming 调用与运行的 provider 字段匹配的 provider worker 上的 provider::<name>::stream。Provider worker 通过 auth::get_token(auth-credentials)拉取凭证,将模型的 SSE 响应流式传入 iii 通道,编排器从该通道读取数据,并在 agent::events 上发出 message_update 事件以供 UI 分发。通道创建和读取循环位于 provider-stream.ts 中基于拉取的 MessagePump 之后,因此流式传输状态保持专注于转换。
当助手返回工具调用时,FSM 进入 function_execute。每个工具调用都通过 dispatchWithHook 传递,这是编排器中的单一阻塞点。consultBefore 直接调用 policy::check_permissions,超时时间为 5 秒。策略 worker(默认堆栈中的 harness 元 worker)读取 iii-permissions.yaml,将调用的 function_id 与规则集匹配,并返回三种结果之一:
allow:分派继续;编排器触发目标函数并写入结果。deny:分派短路,返回DenialEnvelope,结果成为拒绝记录。needs_approval:单个调用被放入 turn 的awaiting_approval列表。批次中的其余调用继续分派。仅当有一个或多个条目等待时,turn 才会转换为function_awaiting_approval。
审批唤醒是反应式且共享的。编排器在 approvals 作用域上注册一个 turn::on_approval 状态触发器。当控制台调用 approval::resolve 时,审批网关 worker 将 approvals/<sid>/<cid> = {decision, reason} 写入 iii 状态。该写入触发 turn::on_approval,进而推进受影响的会话。function_awaiting_approval 只读取刚到达的决策,每个决策到达时分派一次(allow 成为预批准的分派,deny 或 aborted 成为合成拒绝),并在 awaiting_approval[] 为空时继续前进。无需为每个调用注册恢复函数。无需启动时重新扫描以恢复待处理的审批。一个触发器覆盖所有会话。
默认情况下故障关闭:如果策略 worker 不可达或 5 秒超时触发,consultBefore 以 gate_unavailable 信封拒绝调用。如果 iii::durable::publish 本身出错,钩子分发器返回 publish_failed: true,编排器将其视为拒绝。
这种形状带来了一些延迟优化。函数调用后钩子通过订阅者存在性缓存在没有注册该主题的持久化订阅者时短路 publish_collect,每个执行的函数调用减少了大约 500ms。tearing_down 被内联到 finishSession() 中,每个 turn 减少一个持久化队列跳转。context-compaction 订阅编排器在 turn 边界发出的专用 agent::turn_end 流,因此压缩器唤醒是每 turn 一次,而不是每事件一次。会话创建分发状态触发器仅按作用域门控并在进程内匹配,因此之前每次写入的 harness::session::is_create_event RPC 消失了。
批次完成后,steering_check 决定是继续、停止还是达到最大 turn 数。如果继续,循环回到 assistant_streaming。如果停止或达到最大 turn,finishSession() 内联运行:发出 agent_end,释放沙箱,转换到 stopped。
在整个运行过程中,每个参与的 worker 都会发出带有 iii.session.id、iii.message.id 和 iii.function.id 标签的 OTel span。这些标签就是引擎的 engine::traces::group_by 用来填充追踪 UI 中“按会话分组”/“按消息分组”/“按函数分组”的内容。插桩是自动的:src/runtime/worker.ts 用 Proxy 包装每个 registerFunction,因此每个 worker 代码无需记住添加 span。
构建你自己的 Harness
有趣的是,上面的 worker 没有一个特殊。每个都是一个进程,打开一个到引擎的 WebSocket,注册一些函数和触发器,然后运行。契约与你每个应用程序 worker 使用的契约相同。Harness 构建在与业务逻辑相同的原语之上。
这意味着“构建你自己的 harness”分解为与“编写任何 worker”相同的操作。你选择要替换的层,编写一个在总线上注册相同函数的 worker,执行 iii worker add,堆栈的其余部分就会开始使用你的 worker。
上表中没有出现两个层,但它们对 harness 的行为很重要。技能是每个 worker 如何宣传其函数功能的方式。每个 worker 可以在 iii://<worker>/<function> 发布一个技能,Agent 在首次调用该函数之前通过 directory::skills::get 获取该技能。系统提示词每 turn 组装一次,由模式段落、iii 身份序言和运行配置的默认技能体组成。两者都是总线驱动的:技能由 iii-directory worker 提供,系统提示词由 turn-orchestrator 组装。两者都是可替换的。
五个具体示例。
用实时 API 替换模型目录。 编写一个 worker 注册 models::list、models::get、models::supports。让它每 N 分钟从你的 provider 的目录端点获取并缓存。发布它。执行 iii worker add your-org/dynamic-models-catalog。停止静态的 models-catalog worker。编排器完全不知道。它调用 iii.trigger('models::list'),引擎路由到最近注册该函数 ID 的 worker。
添加一个新的 Provider。 provider-kimi 和 provider-lmstudio 已经证明了这种模式。每个都是一个 worker,注册 provider::<name>::stream 和 provider::<name>::complete,将上游 API 的 SSE 流排入 iii 通道,并通过 budget::record 将模型使用情况写入 llm-budget。添加第五个 provider 就是编写一个包含一个 iii.worker.yaml 和一个 register.ts 的文件夹。发布到注册表,或者保持本地。编排器根据运行的 provider 字段选择 provider;新的 provider 在 worker 连接时立即可用。
从私有存储库提供技能。 编写一个 worker 注册 directory::skills::get 和 directory::skills::list,由你的内部文档系统或私有 S3 存储桶支持。断开或重命名默认的 iii-directory worker。编排器的引导程序调用每个命名空间的 directory::skills::download;你的 worker 作出响应。Agent 的“在调用新函数之前获取每个函数的技能”模式保持不变,因为传输格式相同。
完全覆盖系统提示词。 run::start 接受一个可选的 system_prompt 字段。传递它,编排器将直接使用你的字符串,跳过模式段落 + 身份序言 + 技能附录的组装。当你有一个现有的提示词资产希望 harness 不加修改地使用时,这很有用。技能下载仍然在引导程序中运行,因此即使使用自定义提示词,Agent 仍然可以进行 directory::skills::get 按需发现。
替换审批网关 UI 表面。 默认的审批网关 worker 注册 approval::resolve。传输模式是一个函数调用:
{ session_id, call_ids: [{ cid, decision, reason }] }
该处理程序将 approvals/<sid>/<cid> = {decision, reason} 写入 iii 状态。编排器的单个 turn::on_approval 状态触发器捕获该写入并唤醒正确的会话。如果你想从 Slack 而不是控制台驱动审批,编写一个 Slack worker,监听 /approve <id> 和 /deny <id> 斜杠命令,然后使用正确的 payload 调用 approval::resolve。编排器完全不知道。整个审批网关 worker 保持不变。你添加了一个新 worker;你没有替换现有的 worker。
如果你想要不同的策略引擎(OPA、Cedar、你自己的 DSL),编写一个 worker 注册 policy::check_permissions 并返回 { decision, rule_id?, matched_constraint? }。断开默认的策略 worker(它包装在 harness 元 worker 内部,因此你将禁用该处理程序或运行一个精简版的元 worker)。编排器的 consultBefore 不知道区别。相同的 5 秒超时,相同的故障关闭语义,相同的传输格式。
这些示例的重点不在于特定的替换。而在于操作的形式。iii 堆栈中的每个 harness 层都可以通过总线上的一个或两个函数 ID 访问。替换一层就是编写一个注册这些 ID 的 worker。系统的其余部分保持不变。
Harness 是滑块,而不是岔路口
经典的 harness 辩论将其定义为薄与厚。Anthropic 的薄循环与 LangGraph 的显式 DAG。这种表述假设你选择了其中一方并坚持下去。
当 harness 由同一总线上的一组 worker 组成时,薄与厚仅仅是你安装了多少个 worker 的计数。一个薄 harness 就是 turn-orchestrator 加上 provider-anthropic 加上 auth-credentials 再加上一个最小的 harness meta-worker。仅此而已。没有审批,没有预算,没有策略引擎,没有钩子分发。运行任何东西。信任模型。适用于自主研究 Agent、实验性循环、任何内部用途。
一个厚 harness 是所有十三个 worker 加上 context-compaction 加上自定义策略 worker 加上自定义审批网关加上 Slack 集成的审批表面加上执行按工作区上限的预算 worker。适用于运行客户工作流的 Agent,其中每个工具调用都需要可审计,每个模型花费都需要汇总到财务仪表板。
薄与厚之间的架构距离不是重写。而是配置变更。相同的传输协议,相同的追踪形状,相同的可观测性故事。滑块通过从你的 config.yaml 中添加和删除 worker 来移动。其他一切保持不变。
它同样适用于单个 worker 内部。turn-orchestrator 刚刚发布了一次重构,将其 FSM 从十一个状态减少到七个,删除了每个调用的 turn::approval_resume::<sid>/<cid> 机制,转而使用一个在 approvals 作用域上的反应式 turn::on_approval 状态触发器,并将 tearing_down 内联到 finishSession() 端口。堆栈中的每个其他 worker(approval-gate, session, llm-budget, providers, models-catalog, auth-credentials, hook-fanout, context-compaction)保持不变。approval::resolve 的传输格式没有变化。契约保持不变。这就是组合赋予你的属性:对一个 worker 的重大内部重写是一个自包含的变更,因为每个邻居都通过总线级别的函数 ID 与它通信。
这是框架模型无法给你的部分。框架为你选择了一个滑块位置并将你锁定。Worker 模型将滑块留在你手中。
这在实践中意味着什么
如果你一直在框架之上运行 Agent,并且感受到大多数团队在扩展时会遇到的相同边界问题,答案可能不是“在我们自己的框架中重写 harness”。策略引擎无法按你需要的方式扩展。审批 UI 与框架的聊天界面绑定。凭证存储无法与你的密钥管理器通信。预算追踪器在追踪无法看到的独立数据库中。答案是切换到一个底层结构,其中 harness 从一开始就是分解的。
感受这一论点最快的方法是克隆 github.com/iii-hq/workers,运行 pnpm install、pnpm build,然后运行组合入口点。你将获得指向 iii 引擎的完整十四 worker harness。你可以通过从启动列表中移除条目来禁用任何 worker。你可以通过编写替换 worker 来注册相同的函数 ID 来替换任何 worker。你可以通过向钩子主题添加订阅者来扩展任何 worker。hook-fanout::publish_collect 是每个 iii 钩子构建的通用基础。
文档在 iii.dev/docs。引擎在 github.com/iii-hq/iii。Worker 注册表在 workers.iii.dev。Harness 捆绑包在 github.com/iii-hq/workers/harness。
赌注
Harness 不是你要安装的东西。Harness 是你的系统为了让 Agent 持久、安全且可观测地运行而必须完成的一组工作。框架时代将这些工作捆绑在一起,因为底层没有提供组合它们的方式。
iii 的赌注是:一个原语——一个通过 WebSocket 连接到引擎并注册函数和触发器的 worker——足够小,可以分别承担这些工作中的每一项,并且由此产生的堆栈比任何框架都更有用,因为每一层都是独立可替换的。
你不会采用 iii 的 harness。你安装你想要的 worker,编写你需要的 worker,最终得到一个完全符合你系统形状的 harness。每一层使用相同的协议。每次调用使用相同的追踪。对于从注册表取用的部分和你自己发布的部分,都使用相同的 iii worker add 命令。
这就是当底层结构形状正确时,“构建你自己的 Agent harness”的样子。挑选 worker。编写缺失的。组合。Harness 就是组合本身。
加入我们,共同构建现代世界所需的完美 Agent harness:discord.gg/iiidev
iii 是开源的。从 iii.dev/docs 开始。Harness worker 在 github.com/iii-hq/workers,引擎在 github.com/iii-hq/iii。
— Mike Piccolo,创始人兼 CEO @iiidevs





