Source: Mike Piccolo on X · 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 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 背后的赌注是:这些东西不该被打成一个块。应该有一组 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 拆回它的职责,大概会得到下面这张清单:
-
从 client 接收一次 turn request,并把它持久化
-
解析要调用的 model provider 所需的 credentials
-
查出选定模型到底支持什么能力(vision、tools、streaming、context window)
-
驱动每次 turn 的 state machine:provision、stream assistant、run tools、steer、tear down
-
加载并提供 skill bodies,说明每个 function 的请求结构、error codes 和 usage notes
-
组装 system prompt、mode paragraph、identity preamble、working directory 和 default skills appendix
-
在模型生成 token 时,把 token 流式回传给 client
-
每个 tool call(也就是一个 function)运行前,先过一遍 policy
-
暂停需要人工决策的 tool call,并把答案路由回正确的 turn
-
按 workspace 或 agent 的 budget 追踪 LLM 开销
-
在 tool call 前后运行 hooks(logging、redaction、自定义 side effects)
-
把 session 持久化成 branching tree,让 fork 和 resume 能工作
-
context window 满了以后压缩 session history
-
发出 UI 订阅的 event stream
-
我看到每家做 agent 的公司都缺这一块:让一条 OpenTelemetry trace 贯穿每一步,这样出问题时才查得出来
每套成熟的 agent harness 都会做其中大多数。贵的方案会全做。便宜的方案会先偷工减料,等进了生产环境再回过头来补。framework 则把这些职责打包成一套单体方案,然后每件事都只给你一个版本。真正让你付出代价的是最后这点:一年后你发现,自己想要的 policy engine,不是 framework 自带的那个 policy engine;而替换它意味着替换整个 harness。
iii harness 把这些任务里的每一件,都作为一个独立 worker 发布在 workers.iii.dev registry 上。每个 worker 都使用同一种 WebSocket protocol。每个 worker 都在同一个 engine bus 上 register functions 和 triggers;都能通过 iii worker add 安装;都能被替换;也都能用任何有 SDK 的语言自己写。
按 worker 拆开的 stack
这是 iii-hq/workers monorepo 里的实际生产 stack,每个 worker 的职责用一句话写出来。整个 bundle 发布在 github.com/iii-hq/workers/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 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_approvallist 里。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://
看五个具体例子。
把 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:
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,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。engine 在 github.com/iii-hq/iii。worker registry 在 workers.iii.dev。harness bundle 在 github.com/iii-hq/workers/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 开始。harness workers 在 github.com/iii-hq/workers,engine 在 github.com/iii-hq/iii。
— Mike Piccolo, Founder & CEO @iiidevs
原文链接:https://x.com/mfpiccolo/status/2060069083878408689