# 如何搭建你自己的 agent harness
> **Source**: [Mike Piccolo on X](https://x.com/mfpiccolo/status/2060069083878408689) · 2026-05-28T18:41:58.000Z · 大多数 agent 团队不会自己搭 harness。他们会直接采用一个现成的。LangChain、LangGraph、OpenAI Agents SDK、Anthropic SDK、CrewAI、AutoGen,连同 loop、工具、memory 和编排一起,被当成一个决定拿来用。

大多数 agent 团队不会自己搭 harness。他们会直接采用一个现成的。LangChain、LangGraph、OpenAI Agents SDK、Anthropic SDK、CrewAI、AutoGen,连同 loop、工具、memory 和编排一起,被当成一揽子方案拿来用。harness 是你 import 进来的 framework。如果里面某个部分不合适,**你只能 fork 它、跟它较劲,或者绕着它走。**

问题就在这里:这套架构从根上就**不对**。所以每个长期运行 agent 的团队,最后都会从头重写自己的 harness。harness 本来就不是一个东西。现在所谓的 harness,其实是十个、十二个不同东西被打包在一起,只因为周围生态没给你组合它们的办法。**[Pi agent](https://github.com/earendil-works/pi/tree/main/packages/coding-agent) packages** 方向是对的,但 Pi packages 还停在“再加一个服务,然后跟其他所有服务集成起来”的范式里。iii engine 把所有 worker 一视同仁,直接拿掉了集成逻辑。provider router、credential vault、policy engine、approval gate、model catalog、session storage、budget tracker、after-call hook fanout,以及 durable turn loop,都是彼此独立的职责。这些职责都能跟你的 queue、HTTP/API server、streaming,甚至 browser worker 互操作。把这些东西打成一个块交付的 framework,兜售的是一个你本不必做的取舍。
[iii](https://iii.dev/) 背后的赌注是:这些东西不该被打成一个块。应该有一组 worker 跑在共享 engine 上,每个都能替换,每个都能独立发版,全部通过一个原语连接:**trigger(iii.trigger())**,而且每个 worker 都用同一个 trigger。harness 变成一组可安装的 worker,“**build your own**” 不再意味着“**fork 一个 framework**”,而是“**换掉几个 worker**”。
这篇文章会把 iii harness 真正长什么样讲清楚:今天驱动一次 iii agent turn 的完整 stack,每一层为什么都是自己的 worker,以及你怎么替换其中任意一层。
## Agent harness 必须完成的 15 件事
如果把一个生产级 agent harness 拆回它的职责,大概会得到下面这张清单:
1. 从 client 接收一次 turn request,并把它持久化
1. 解析要调用的 model provider 所需的 credentials
1. 查出选定模型到底支持什么能力(vision、tools、streaming、context window)
1. 驱动每次 turn 的 state machine:provision、stream assistant、run tools、steer、tear down
1. 加载并提供 skill bodies,说明每个 function 的请求结构、error codes 和 usage notes
1. 组装 system prompt、mode paragraph、identity preamble、working directory 和 default skills appendix
1. 在模型生成 token 时,把 token 流式回传给 client
1. 每个 tool call(也就是一个 function)运行前,先过一遍 policy
1. 暂停需要人工决策的 tool call,并把答案路由回正确的 turn
1. 按 workspace 或 agent 的 budget 追踪 LLM 开销
1. 在 tool call 前后运行 hooks(logging、redaction、自定义 side effects)
1. 把 session 持久化成 branching tree,让 fork 和 resume 能工作
1. context window 满了以后压缩 session history
1. 发出 UI 订阅的 event stream
1. 我看到每家做 agent 的公司都缺这一块:让一条 OpenTelemetry trace 贯穿每一步,这样出问题时才查得出来
每套成熟的 agent harness 都会做其中大多数。贵的方案会全做。便宜的方案会先偷工减料,等进了生产环境再回过头来补。framework 则把这些职责打包成一套单体方案,然后每件事都只给你一个版本。真正让你付出代价的是最后这点:一年后你发现,自己想要的 policy engine,不是 framework 自带的那个 policy engine;而替换它意味着替换整个 harness。
iii harness 把这些任务里的每一件,都作为一个独立 worker 发布在 [workers.iii.dev](https://workers.iii.dev/) registry 上。每个 worker 都使用同一种 WebSocket protocol。每个 worker 都在同一个 engine bus 上 register functions 和 triggers;都能通过 `iii worker add` 安装;都能被替换;也都能用任何有 SDK 的语言自己写。
## 按 worker 拆开的 stack
这是 [iii-hq/workers](https://github.com/iii-hq/workers/tree/main/harness) monorepo 里的实际生产 stack,每个 worker 的职责用一句话写出来。整个 bundle 发布在 [github.com/iii-hq/workers/harness](https://github.com/iii-hq/workers/tree/main/harness):

十一个 worker。一个 engine。每个都有发布版本。每个都可以作为独立进程运行(开发时用 `pnpm dev:<worker>`,发布版用 `iii worker add <specific-worker>`),也可以通过复合入口一起拉起来。
这之所以重要,是因为表里的每个格子,都是可以换成另一个 worker 的位置;换掉这一格,其余部分还能保住。不喜欢 static model catalog?接一个 worker,register `models::list`,从 live API 读。文件式 credentials 不合适?接一个 worker,注册 `auth::get_token`,从 secrets manager 读。想给某个会按不同方式分支的 workflow 换一套 turn FSM?替换 `turn-orchestrator`。所有依赖方还是调用 `run::start`,还是通过同一条 bus 读 `turn_state`,所以 stack 里的其他部分不用动。
## Loop 实际怎么跑
一次 turn 大概这样跑,按 worker 被触发的顺序走一遍。
browser/CLI/chat 通过 `harness::trigger` POST 一次 turn,payload 是 `{session_id, message_id, payload}`。harness meta-worker 把 payload 转给 `run::start`。多这一跳,是为了让 OpenTelemetry span wrapper 把 session ID 和 message ID 种成 baggage,之后 stack 里每个 worker 的嵌套 iii.trigger call 都能带着这两个 ID 传播。另一头看到的 trace tree 会连成一张图。
`run::start` 到达 turn-orchestrator。它持久化 run request,在 iii state 的 `session/<sid>/turn_state` 里种下初始 TurnStateRecord,然后立刻返回。真正的工作发生在 durable per-state machine 里,由发布到 turn-step FIFO 的消息唤醒。
两个终态是 `stopped`(通过 `finishSession()` clean exit)和 `failed`(handler 意外 throw 会走这里,queue 会被 ack 掉,停止 retry,并发出 `message_complete{stop_reason:'error'}` 加 `agent_end`,让 UI 显示原因)。Teardown 是从任意 turn-end path 内联调用的 `finishSession()` port,不是另一个单独入队的 step。
provisioning 做三件事。如果这个 run 需要隔离执行,orchestrator 会启动一个 [iii-sandbox](https://github.com/iii-hq/iii/tree/main/crates/iii-worker/src/sandbox_daemon) microVM。orchestrator 会为 system_default_skills 里的每个 namespace(默认 `["iii://iii-directory/index"]`)调用 directory::skills::download,让 iii-directory 预缓存这个 run 启动时带的 skill bodies。orchestrator 还会分三层组装 system prompt:一段从 run_request.mode 选出来的 mode paragraph(plan、ask 或 agent),iii identity preamble(教模型 agent_trigger 约定和 directory::skills::get 按需发现模式),以及 default skills 的 appended index。caller 可以在 run::start 里传 system_prompt 来覆盖整个 prompt;否则由 orchestrator 构建。Function schemas 来自实时 engine catalog。
`assistant_streaming` 会调用 `provider::<name>::stream`,目标是跟这个 run 的 `provider` field 匹配的 provider worker。provider worker 通过 auth::get_token(auth-credentials)拉 credentials,把模型的 SSE response 写进一个 iii channel。orchestrator 读取这个 channel,并在 agent::events 上发 message_update event 给 UI fanout。channel creation 和 read loop 被封在 provider-stream.ts 里的 pull-based MessagePump 后面,所以 streaming state 只需要专注状态推进。
assistant 返回 tool calls 后,FSM 进入 `function_execute`。每个 tool call 都会经过 `dispatchWithHook`,这是 orchestrator 里的唯一关口。`consultBefore` 直接调用 `policy::check_permissions`,timeout 是 5 秒。policy worker(默认 stack 里由 harness meta-worker 承担)读取 iii-permissions.yaml,用 function_id 匹配 rule set,然后返回三种结果之一:
- `allow`:dispatch 继续;orchestrator trigger 目标 function,并写入结果
- `deny`:dispatch 短路,返回 DenialEnvelope,结果变成一条 denial record
- `needs_approval`:这一个 call 会停在这个 turn 的 `awaiting_approval` list 里。batch 里的其他 call 继续 dispatch。只有当有一个或多个 entry pending 时,turn 才会 transition 到 `function_awaiting_approval`
approval 的唤醒是响应式的,也是共享的。orchestrator 只注册一个 `turn::on_approval` state trigger,scope 是 approvals。console 调用 approval::resolve 时,approval-gate worker 把 `approvals/<sid>/<cid> = {decision, reason}` 写进 iii state。这个写入触发 `turn::on_approval`,推进受影响的 session。`function_awaiting_approval` 只读取刚落下来的 decision,每收到一个就 dispatch 一个(allow 变成 pre-approved dispatch,deny 或 aborted 变成 synthetic denial),等 `awaiting_approval[]` 清空后继续前进。不需要为每个 call 注册 resume function。不需要启动时重新扫描来恢复 pending approvals。一个 trigger 覆盖所有 session。
这道门天生 fail-closed:如果 policy worker 不可达,或者 5 秒 timeout,consultBefore 会用 gate_unavailable envelope deny 掉这个 call。如果 `iii::durable::publish` 本身报错,hook fanout 返回 `publish_failed: true`,orchestrator 也会把它当成 deny。
这套拆法顺手省掉了几处延迟。after-function-call hook 在没有 durable subscriber 注册这个 topic 时,会通过 subscriber-presence cache 短路 `publish_collect`,每个已执行 function call 大约少 500ms。`tearing_down` 被内联进 `finishSession()`,每次 turn 少一个 durable queue hop。context-compaction 订阅 orchestrator 在 turn boundary 发出的专用 `agent::turn_end` stream,所以 compactor wakeup 按 turn 触发,而不是按 event 触发。session-create fanout state trigger 只按 scope gate,并且在进程内 match,于是之前每次写入都要打的 `harness::session::is_create_event` RPC 没了。
batch 完成后,`steering_check` 决定继续、停止,还是达到 `max_turns`。如果继续,就 loop 回 assistant_streaming。如果停止或 max,`finishSession()` 内联运行:发 agent_end,释放 sandbox,transition 到 stopped。
整个 run 期间,参与进来的每个 worker 都会发 OTel spans,并打上 iii.session.id、iii.message.id 和 iii.function.id。engine 的 engine::traces::group_by 会读取这些 tags,在 traces UI 里填出 “Group by Session” / “Group by Message” / “Group by Function”。instrumentation 是自动的:`src/runtime/worker.ts` 用 Proxy 包住每个 `registerFunction`,worker 代码不需要自己记得加 spans。
## Build your own
有意思的是,上面这些 worker 没有一个是特殊的。每个都是一个进程,打开一条 WebSocket 连到 engine,注册一些 functions 和 triggers,然后运行。contract 跟每个 application worker 用的 contract 一样。harness 建在跟你业务逻辑同一个原语之上。
这意味着,"build your own harness"跟"写任何 worker"本质上是同一件事。你选定要替换哪一层,写一个 worker 在 bus 上 register 同样的 functions,用 `iii worker add` 装上它,然后 stack 其他部分就开始用你的 worker。
上面的 worker table 里有两层没出现,但对 harness 行为很重要。**Skills** 是每个 worker 说明自己 functions 做什么的方式。每个 worker 都可以在 iii://<worker>/<function> 发布一个 skill,agent 第一次调用那个 function 前,会通过 directory::skills::get 拉取对应 skill。**system prompt** 则是每个 turn 从 mode paragraph、iii identity preamble 和这个 run 配置的 default skill bodies 组装出来的。两者都走 bus:skills 由 iii-directory worker 提供,system prompt 由 turn-orchestrator 组装。两者也都能替换。
看五个具体例子。
**把 model catalog 换成 live API。** 写一个 worker,register `models::list`、`models::get`、`models::supports`。让它每 N 分钟从 provider 的 catalog endpoint fetch 一次并 cache。发布它。`iii worker add your-org/dynamic-models-catalog`。停掉 static models-catalog worker。turn-orchestrator 完全不知道有什么不同。它调用 iii.trigger('models::list'),engine 会把请求路由到最新 register 这个 function id 的 worker。
**添加一个新 provider。** provider-kimi 和 provider-lmstudio 已经证明这条路能走通。每个都是一个 worker,register `provider::<name>::stream` 和 `provider::<name>::complete`,从 upstream API drain SSE stream 到 iii channel,并把 model usage 写进 llm-budget 的 budget::record。添加第五个 provider,就是写一个 folder,里面放一个 iii.worker.yaml 和一个 register.ts。发到 registry,或者保留在本地。turn-orchestrator 按 run 的 provider field 选择 provider;新 provider 在 worker 连接的一瞬间就可用了。
**从私有 artifact store 提供 skills。** 写一个 worker,register `directory::skills::get` 和 `directory::skills::list`,背后接你的内部 docs system 或 private S3 bucket。断开或重命名默认 iii-directory worker。orchestrator 的 bootstrap 会按 namespace 调用 `directory::skills::download`;请求会落到你的 worker。agent “第一次调用新 function 前先 fetch 对应 skill” 的模式不用改,因为接口契约一样。
**完全覆盖 system prompt。** `run::start` 接受一个可选的 `system_prompt` field。传了它,orchestrator 就逐字使用你的字符串,跳过 mode paragraph + identity preamble + skills appendix 的组装。如果你已经有一份 prompt asset,想让 harness 原样尊重它,这很有用。skill download 仍然会在 bootstrap 里运行,所以即使用 custom prompt,agent 也保留 `directory::skills::get` 的按需发现能力。
**替换 approval gate 的 UI 入口。** 默认 approval-gate worker register `approval::resolve`。wire schema 只有一个 function call:
```typescript
iii.trigger('approval::resolve', {
session_id: '...',
function_call_id: '...',
decision: 'allow' | 'deny' | 'aborted',
reason: 'optional human text',
})
```
handler 会把 `approvals/<sid>/<cid> = {decision, reason}` 持久化到 iii state。orchestrator 的单个 `turn::on_approval` state trigger 会接住这个 write,唤醒正确的 session。如果你想从 Slack 而不是 console 处理 approvals,写一个 Slack worker 监听 `/approve <id>` 和 `/deny <id>` slash commands,再带着正确 payload 调 `approval::resolve`。orchestrator 完全不知道有什么不同。整个 approval-gate worker 不用动。你加了一个新 worker;没有替换现有 worker。
如果你想换一个 policy engine(OPA、Cedar、你自己的 DSL),写一个 worker,register `policy::check_permissions`,返回 `{ decision, rule_id?, matched_constraint? }`。断开默认 policy worker(它被包在 harness meta-worker 里,所以你要 disable 那个 handler,或者跑一个 stripped-down meta-worker)。turn-orchestrator 的 consultBefore 不知道有什么不同。同样 5 秒 timeout,同样 fail-closed 语义,同样接口契约。
这些例子的重点不是具体替换哪个。重点是替换这个操作的形态。iii stack 里的每个 harness layer,都能通过 bus 上一两个 function id 找到。替换某一层,就是写一个注册这些 id 的 worker。系统其他部分不动。
## Harness 是一根滑杆,不是岔路口
经典 harness 争论喜欢把问题框成 thin vs thick。Anthropic 的 thin loop 对 LangGraph 的 explicit DAG。这种问法预设你要选一边,然后跟它过日子。
当 harness 由同一条 bus 上的 worker 组合而成时,thin vs thick 只是你安装多少个 worker 的数量问题。一个 thin harness 可以是 turn-orchestrator 加 provider-anthropic,加 auth-credentials,加一个 minimal harness meta-worker。就这些。没有 approvals,没有 budgets,没有 policy engine,没有 hook fanout。随便跑。信任模型。适合 autonomous research agents、experimental loops,以及任何内部场景。
一个 thick harness 则是全部十三个 worker,加 context-compaction,加 custom policy worker,加 custom approval-gate,加 Slack-integrated approval 入口,再加 budget worker 执行 per-workspace caps。适合跑 customer workflows 的 agent:每个 tool call 都要 auditable,每笔 model 开销都要汇总到 finance dashboard。
thin 和 thick 之间的架构距离不是重写。是改配置。同一个 wire protocol,同一种 trace 形态,同一套 observability story。在 config.yaml 里增删 worker,这根滑杆就跟着移动。其他东西都不变。
同一条边界也适用于单个 worker 内部。turn-orchestrator 刚刚发了一个 refactor:把 FSM 从十一个 state 压到七个,删掉每个 call 一套的 `turn::approval_resume::<sid>/<cid>` 机制,改成一个响应式 `turn::on_approval` state trigger,scope 是 approvals,并把 `tearing_down` 内联成一个 `finishSession()` port。stack 里其他 worker(approval-gate、session、llm-budget、providers、models-catalog、auth-credentials、hook-fanout、context-compaction)全都不用动。approval::resolve 的接口契约没变。contracts 立住了。这就是组合带来的性质:一个 worker 内部做一次大重写,仍然是自包含变更,因为所有邻居都只通过 bus-level function ids 跟它说话。
这是 framework 模式给不了你的部分。framework 会替你在滑杆上选一个位置,然后把你锁在那里。worker 模式把滑杆留在你手里。
## 实践里意味着什么
如果你一直在 framework 上跑 agent,并且开始碰到多数团队到规模化时都会碰到的边界问题,答案大概率不是“用我们自己的 framework 重写 harness”。policy engine 没法按你需要的方式扩展。approval UI 被绑在 framework 的 chat surface 里。credential store 接不上你的 secrets manager。budget tracker 在一个 trace 看不到的 sidecar database 里。答案是换到底层已经拆开的 substrate 上。
最快感受到这套思路的办法,是 clone [github.com/iii-hq/workers](https://github.com/iii-hq/workers),`pnpm install`,`pnpm build`,然后跑 composite entry point。你会得到一个指向 iii engine 的完整十四 worker harness。你可以从 boot list 里删掉任意 worker 来禁用它。你可以写一个注册相同 function ids 的 replacement,替换任意 worker。你可以给任何 worker 的 hook topics 加 subscriber 来扩展它。每个 iii hook 都建在 hook-fanout::publish_collect 这个通用件上。
Docs 在 [iii.dev/docs](https://iii.dev/docs)。engine 在 [github.com/iii-hq/iii](https://github.com/iii-hq/iii)。worker registry 在 [workers.iii.dev](https://workers.iii.dev/)。harness bundle 在 [github.com/iii-hq/workers/harness](https://github.com/iii-hq/workers/tree/main/harness)。
## 赌注
harness 不是一个你安装的东西。harness 是一组你的系统必须完成的工作,只有这样 agent 才能持久、安全、可观测地运行。framework 时代把这些工作绑在一起,是因为底下没有提供组合这些工作的方式。
iii 的赌注是:一个连接到 engine、注册 functions 和 triggers 的 WebSocket worker,这个原语足够小,小到可以分别承载这些工作中的每一项;而拼出来的 stack 比任何 framework 都有用,因为每一层都能独立替换。
你不是采用 iii harness。你安装自己想要的 worker,写自己需要的 worker,最后得到一个完全贴合你系统的 harness。每一层同一个 protocol。每一次 call 同一条 trace。你从 registry 拿来的部分,和你自己发布的部分,用的都是同一个 `iii worker add`。
这就是 substrate 选对时,“build your own agent harness” 该有的样子。挑 worker。写缺的那些。组合。harness 就是这种组合。
加入我们,一起搭出现代世界需要的完美 agent harness:discord.gg/iiidev
iii 是开源的。从 [iii.dev/docs](https://iii.dev/docs) 开始。harness workers 在 [github.com/iii-hq/workers](https://github.com/iii-hq/workers),engine 在 [github.com/iii-hq/iii](https://github.com/iii-hq/iii)。
— Mike Piccolo, Founder & CEO @iiidevs
**原文链接**:https://x.com/mfpiccolo/status/2060069083878408689