如何构建你自己的 Agent 运行时

2026年5月28日 · Mike Piccolo, iii 创始人兼 CEO


大多数 agent 团队不构建运行时。他们采用一个。LangChain、LangGraph、OpenAI Agents SDK、Anthropic SDK、CrewAI、AutoGen——循环、工具、记忆、编排,都是作为一个单一决策从货架上挑选的。运行时是一个你 import 的框架。如果里面的什么东西不合适,你就 fork 它、跟它斗争、或者绕过它。

我认为这种形态是错的,这就是为什么每个长期运行的 agent 团队最终都会从头重写它的运行时。运行时不是一个东西。它是十到十二个不同的东西被捆绑在一起,因为周围的生态系统没有给你组合它们的方式。Pi agent 的包化走在了正确的路上,但它们仍然处在"再添加一个服务并与所有其他服务集成"的范式中。iii 引擎将所有 worker 一视同仁,完全移除了集成逻辑。provider 路由器、凭证保管库、策略引擎、审批网关、模型目录、会话存储、预算追踪器、调用后 hook 扇出、持久化的 turn 循环——这些都是独立的关注点。它们全都可以与你的队列、HTTP/API 服务器、流式传输、甚至浏览器 worker 互操作。一个把它们当作整体发布的框架,是在卖给你一个你本不必做的权衡。

iii 底层的赌注是:它们不应该是一个整体。应该有一组 worker 运行在共享引擎上,每一个都可替换,每一个可独立版本化,每一个通过一个单一原语连接起来:一个触发器(iii.trigger()),其他每个 worker 也都使用它。运行时变成一组可安装的 worker 堆栈,"构建你自己的"不再意味着"fork 一个框架",而是意味着"替换几个 worker"。

本文带你看看这实际上是什么样的。今天驱动一个 iii agent turn 的完整技术栈,为什么每一层都是自己的 worker,以及你如何替换其中任何一层。

Agent 运行时必须完成的 15 项工作

如果你把一个生产级 agent 运行时剥离回它的职责,你会得到大致像这样的一个列表:

  1. 接受来自客户端的 turn 请求并持久化它

  2. 为被调用的模型 provider 解析凭证

  3. 查询所选模型实际能做什么(视觉、工具、流式传输、上下文窗口)

  4. 驱动每个 turn 的状态机:预配、流式传输 assistant、运行工具、引导、清理

  5. 加载并提供 skill 描述体,说明每个函数的请求格式、错误码和使用说明

  6. 组装系统提示词:模式段落、身份前言、工作目录、默认 skill 附录

  7. 当模型产生 token 时将 token 流式推送回客户端

  8. 在运行前检查每个工具调用(这只是一个函数)是否符合策略

  9. 暂停需要人工决策的工具调用,并将决策结果路由回正确的 turn

  10. 按工作空间或 agent 跟踪 LLM 支出

  11. 在工具调用前后运行 hook(日志记录、脱敏、自定义副作用)

  12. 将会话持久化为分支树,以便 fork 和恢复正常工作

  13. 当上下文窗口填满时压缩会话历史

  14. 发出 UI 订阅的事件流

  15. 我看到每家 agent 公司构建中都缺失的一块:在每一步携带一条 OpenTelemetry 追踪,这样你才能调试它

每个严肃的 agent 运行时处理其中大多数。昂贵的处理全部。廉价的走捷径,然后在进入生产环境后重新构建那些捷径部分。框架将它们捆绑成一个单体,并发布每样东西的一个版本。最后这一点是让你付出代价的部分,因为一年后,你会发现你想要的策略引擎不是框架自带的那个策略引擎,而替换它意味着替换整个运行时。

iii 运行时将这十三项工作中的每一项都作为独立的 worker 部署在 workers.iii.dev 注册中心上。每个使用相同的 WebSocket 协议。每个在相同的引擎总线上注册函数和触发器。每一个都可以 iii worker add、可替换、可用 SDK 以任何语言编写。

按 worker 拆解技术栈

以下是来自 iii-hq/workers 单体仓库的实际生产级技术栈,每个 worker 的职责用一句话概括。整个代码包发布在 github.com/iii-hq/workers/harness:

Worker 职责
iii-directory Skill 和提示词注册中心。Worker 以 iii://<worker>/<function> 发布 skill;agent 通过 directory::skills::get 按需获取。随 iii 引擎一同发布(Rust)。
harness Meta-worker。加载 iii-permissions.yaml。暴露 policy::check_permissionsui::* 平面。将 agent::events 推送给订阅的浏览器。
turn-orchestrator 驱动每个 agent turn 的持久化 11 状态 FSM。拥有 run::startturn::stepturn::get_state。还在预配阶段组装系统提示词。
approval-gate 操作员决策的总线入口点。将 approval::resolve 路由到编排器注册的每个调用的恢复函数。
session 分支会话存储。session-tree::* 用于父链接条目树;session-inbox::* 用于每个会话的队列。
llm-budget 工作空间 + agent 支出上限。14 个 budget::* 函数,包括检查、记录、告警、预测、周期切换。
hook-fanout 在流主题上通用发布并收集。每个 iii hook 构建于其上的模式。
auth-credentials auth::* 下基于文件的 provider 凭证保管库。
models-catalog 静态模型能力目录。models::listmodels::getmodels::supports
provider-anthropic Anthropic Messages API SSE 流式推送到 iii channel。
provider-openai OpenAI Chat Completions SSE 流式推送到 iii channel。
provider-kimi Kimi(Moonshot)Chat Completions SSE。
provider-lmstudio 用于桌面开发的本地 LM Studio SSE。
context-compaction 可选的 agent::events,在 token 数超过阈值时压缩会话历史。

十一个 worker。一个引擎。每个都有发布的版本。每个都可以作为独立进程运行(开发时 pnpm dev:<worker>,作为发布二进制时 iii worker add <specific-worker>),或者作为将它们一起启动的组合入口点的一部分。

这之所以重要:表中的每个框都是一个别人可以递给你一个不同 worker、而你可以保留其余部分的地方。不喜欢静态模型目录?换一个注册了 models::list 并从实时 API 读取的 worker。不喜欢基于文件的凭证?换一个注册了 auth::get_token 并从密钥管理器读取的 worker。想要一个针对不同分支 workflow 的不同 turn FSM?替换 turn-orchestrator——每个依赖方通过相同的总线调用 run::start 并读取 turn_state,所以技术栈的其余部分不会改变。

循环的实际运行方式

一个 turn 的形态如下,按 worker 的触发顺序逐一遍历。

浏览器、CLI 或聊天客户端通过 harness::trigger POST 一个 turn,携带 {session_id, message_id, payload}。harness meta-worker 将 payload 转发给 run::start。这一跳的存在是为了让 OpenTelemetry span 包装器可以将 session ID 和 message ID 作为 baggage 植入,传播到技术栈中每个 worker 的每个嵌套 iii.trigger 调用。另一端的追踪树是一个连接的图。

run::start 落在 turn-orchestrator 上。它持久化运行请求,在 iii state 的 session/<sid>/turn_state 处植入初始的 TurnStateRecord,然后立即返回。实际工作在持久化的每状态机内部完成,由发布到 turn-step FIFO 的消息唤醒。

两个终止状态是 stopped(通过 finishSession() 正常退出)和 failed(未预期的处理程序抛出被路由到此处,ack 队列使其停止重试,并发出 message_complete{stop_reason:'error'} 加上 agent_end,以便 UI 显示原因)。Teardown 是一个内联的 finishSession() 调用,从任何 turn 结束路径调用,而不是单独的入队步骤。

provisioning 做三件事。如果运行需要隔离执行,它启动一个 iii-sandbox 微虚拟机。它为 system_default_skills(默认为 ["iii://iii-directory/index"])中的每个命名空间调用 directory::skills::download,使 iii-directory 预先缓存运行启动时所需的 skill 描述体。然后它分三层组装系统提示词:从 run_request.mode 中选取的模式段落(planaskagent),iii 身份前言教给模型 agent_trigger 约定和 directory::skills::get 按需发现模式,以及 agent 启动时附带的默认 skill 索引。调用方可以通过在 run::start 上传递 system_prompt 来覆盖整个提示词;否则由编排器构建它。函数 schema 来自实时引擎目录。

assistant_streaming 在匹配此次运行的 provider 字段的 provider worker 上调用 provider::<name>::stream。provider worker 通过 auth::get_token(auth-credentials)拉取凭证,将模型的 SSE 响应流式推送到 iii channel 中,编排器消费该 channel,在 agent::events 上发出 message_update 事件供 UI 扇出。Channel 创建和读取循环位于 provider-stream.ts 中基于拉取的 MessagePump 之后,因此流式状态专注于状态转换。

当 assistant 返回工具调用时,FSM 进入 function_execute。每个工具调用都经过 dispatchWithHook——编排器中唯一的卡控点。consultBefore 直接调用 policy::check_permissions,带有 5 秒超时。策略 worker(在默认技术栈中,就是 harness meta-worker)读取 iii-permissions.yaml,将调用的 function_id 与规则集进行匹配,返回三种结果之一:

  • allow:继续分发;编排器触发目标函数并写入结果
  • deny:以 DenialEnvelope 短路分发,结果成为一条拒绝记录
  • needs_approval:单个调用被停放到 turn 的 awaiting_approval 列表中。批次的其余部分继续分发。仅当有一个或多个待审批条目时,turn 才会转换到 function_awaiting_approval

审批唤醒是响应式且共享的。编排器在 scope approvals 上注册了正好一个 turn::on_approval 状态触发器。当控制台调用 approval::resolve 时,approval-gate worker 将 approvals/<sid>/<cid> = {decision, reason} 写入 iii state。该写入触发 turn::on_approval,推进受影响的会话。function_awaiting_approval 只读取刚刚到达的决策,在每个决策到达时分发它(allow 成为预批准的分发,denyaborted 成为合成的拒绝),并在 awaiting_approval[] 为空时推进。无需为每个调用注册恢复函数。无需启动时重新扫描来恢复待处理的审批。一个触发器覆盖所有会话。

默认拒绝是构造性的:如果策略 worker 不可达,或者 5 秒超时触发,consultBeforegate_unavailable 信封拒绝调用。如果 iii::durable::publish 本身出错,hook fanout 返回 publish_failed: true,编排器将其视为拒绝。

这种形态带来了几项延迟优化。当没有持久化订阅者为该主题注册时,函数调用后 hook 通过订阅者存在缓存短路 publish_collect,每个执行的函数调用移除大约 500 毫秒。tearing_down 被内联到 finishSession() 中,每个 turn 移除一次持久化队列跃点。context-compaction 订阅了编排器在 turn 边界发出的专用 agent::turn_end 流,因此压缩器的唤醒是每个 turn 一次而非每个事件一次。session-create 扇出状态触发器仅通过 scope 进行门控并在进程内匹配,因此之前每次写入的 harness::session::is_create_event RPC 已经消失。

批次完成后,steering_check 决定是继续、停止还是达到 max_turns。如果继续,循环回 assistant_streaming。如果停止或达到上限,finishSession() 内联运行:发出 agent_end,释放 sandbox,转换到 stopped

在整个运行过程中,每个参与的 worker 发出的 OTel span 都带有 iii.session.idiii.message.idiii.function.id 标签。这些标签正是引擎的 engine::traces::group_by 读取的内容,用于在追踪 UI 中填充"按会话分组"/"按消息分组"/"按函数分组"。埋点是自动的:src/runtime/worker.ts 将每个 registerFunction 包装在 Proxy 中,因此不必有任何 worker 代码记住要添加 span。

构建你自己的

有趣的部分在于,以上所有 worker 都不是特殊的。每一个都是一个打开 WebSocket 连接到引擎、注册一些函数和触发器并运行的进程。这个契约与每个应用 worker 使用的契约完全相同。运行时构建在与你的业务逻辑相同的原语之上。

这意味着"构建你自己的运行时"分解成与"编写任何 worker"相同的操作。你选择你想替换的层,你写一个在总线上注册相同函数的 worker,你 iii worker add 它,技术栈的其余部分就开始使用你的 worker。

有两个层没有出现在上述 worker 表中,但对运行时的行为很重要。Skills 是每个 worker 告知其函数功能的机制。每个 worker 可以在 iii://<worker>/<function> 处发布一个 skill,agent 在首次调用该函数之前通过 directory::skills::get 获取它。系统提示词在每个 turn 由模式段落、iii 身份前言以及运行配置的默认 skill 描述体组装而成。两者都是总线驱动的:skill 由 iii-directory worker 提供服务,系统提示词由 turn-orchestrator 组装。两者都可替换。

五个具体示例。

用实时 API 替换模型目录。 写一个 worker,注册 models::listmodels::getmodels::supports。让它每 N 分钟从你 provider 的目录端点获取数据并缓存。发布它。iii worker add your-org/dynamic-models-catalog。停止静态的 models-catalog worker。turn-orchestrator 感知不到任何差异。它调用 iii.trigger('models::list'),引擎路由到最近注册了该函数 ID 的任意 worker。

添加新的 provider。 provider-kimiprovider-lmstudio 已经证明了这种形态。每个都是一个 worker,注册 provider::<name>::streamprovider::<name>::complete,将来自上游 API 的 SSE 流导入 iii channel,并通过 budget::record 将其模型用量写入 llm-budget。添加第五个 provider 就是写一个文件夹,包含一个 iii.worker.yaml 和一个 register.ts。发布到注册中心,或保留在本地。turn-orchestrator 通过每次运行的 provider 字段选择 provider;新 provider 在 worker 连接的瞬间即可使用。

从私有制品库提供 skill。 写一个 worker,注册 directory::skills::getdirectory::skills::list,后端是内部文档系统或私有 S3 存储桶。断开或重命名默认的 iii-directory worker。编排器的 bootstrap 为每个命名空间调用 directory::skills::download;你的 worker 来响应。agent 的"在调用新函数前获取每个函数的 skill"模式保持不变,因为 wire 格式相同。

完全覆盖系统提示词。 run::start 接受一个可选的 system_prompt 字段。传入它,编排器将逐字使用你的字符串,跳过模式段落 + 身份前言 + skill 附录的组装。当你有一个已有的提示词资产,想让运行时原封不动地遵守时,这很有用。Skill 下载仍在 bootstrap 中运行,因此 agent 即使使用自定义提示词也保留 directory::skills::get 按需发现能力。

替换审批网关的 UI 界面。 默认的 approval-gate worker 注册 approval::resolve。wire 模式是一次函数调用:

1
2
3
4
5
6
iii.trigger('approval::resolve', {
session_id: '...',
function_call_id: '...',
decision: 'allow' | 'deny' | 'aborted',
reason: 'optional human text',
})

处理程序将 approvals/<sid>/<cid> = {decision, reason} 持久化到 iii state。编排器唯一的 turn::on_approval 状态触发器捕获该写入并唤醒正确的会话。如果你想从 Slack 而非控制台驱动审批,写一个 Slack worker,监听 /approve <id>/deny <id> 斜杠命令,然后用正确的 payload 调用 approval::resolve。编排器感知不到任何差异。整个 approval-gate worker 保持原封不动。你添加了一个新 worker;你没有替换已有的那个。

如果你想要不同的策略引擎(OPA、Cedar、你自己的 DSL),写一个 worker,注册 policy::check_permissions,返回 { decision, rule_id?, matched_constraint? }。断开默认的策略 worker(它包含在 harness meta-worker 中,所以你需要禁用那个处理程序或者运行一个精简版的 meta-worker)。turn-orchestrator 的 consultBefore 感知不到任何差异。相同的 5 秒超时、相同的 fail-closed 语义、相同的 wire 格式。

这些示例的重点不在于具体的替换项。而在于操作的形态。iii 技术栈中的每个运行时层都可以通过总线上的一两个函数 ID 访问。替换一个层就是写一个注册了这些 ID 的 worker。系统的其余部分保持不变。

运行时是一个滑块,而不是分岔路口

经典的运行时争论以薄 vs 厚的框架来表述。Anthropic 的薄循环 vs LangGraph 的显式 DAG。这种框架假设你选择一边并接受它。

当运行时由同一总线上的 worker 组合而成时,薄 vs 厚只是你安装了多少个 worker 的计数。薄运行时是 turn-orchestrator 加上 provider-anthropic 加上 auth-credentials 加上最小化的 harness meta-worker。仅此而已。没有审批、没有预算、没有策略引擎、没有 hook fanout。运行任何东西。信任模型。适用于自主研究 agent、实验性循环、任何内部用途。

厚运行时是所有 worker 加上 context-compaction 加上自定义策略 worker 加上自定义 approval-gate 加上 Slack 集成的审批界面加上按工作空间执行支出上限的预算 worker。适用于运行面向客户 workflow 的 agent,其中每个工具调用都需要可审计,每次模型支出都需要汇总到财务仪表板。

薄与厚之间的架构距离不是重写。它是一个配置更改。相同的原语、相同的 wire 协议、相同的追踪格式、相同的可观测性方案。通过在 config.yaml 中添加和移除 worker 来移动滑块。其他一切保持不变。

这也适用于单个 worker 内部。turn-orchestrator 刚刚发布了一个重构,将其 FSM 从十一个状态压缩为七个,删除了每个调用的 turn::approval_resume::<sid>/<cid> 机制,代之以在 scope approvals 上的一个响应式 turn::on_approval 状态触发器,并将 tearing_down 内联到 finishSession() 调用中。技术栈中的其他每个 worker(approval-gate、session、llm-budget、providers、models-catalog、auth-credentials、hook-fanout、context-compaction)保持不变。approval::resolve wire 格式没有变化。契约保持住了。这就是组合性带给你的特性:一个 worker 的重大内部重写是自包含的变更,因为每个邻居都通过总线级的函数 ID 与它通信。

这是框架模型无法给你的部分。框架替你选择了滑块上的一个位置并锁定你。worker 模型将滑块留在你手中。

这在实际中意味着什么

如果你一直在某个框架之上运行 agent,并且感受到大多数团队在规模化时遇到的相同的边界问题,那么答案很可能不是"用我们自己的框架重写运行时"。策略引擎无法按你需要的方式扩展。审批 UI 被捆绑在框架的聊天界面里。凭证存储无法与你的密钥管理器对话。预算追踪器位于追踪无法看到的 sidecar 数据库中。答案是切换到一个运行时从一开始就被解耦的基座。

最快感受到这个论点的方法是 clone github.com/iii-hq/workers,pnpm installpnpm build,然后运行组合入口点。你将获得指向 iii 引擎的完整十四 worker 运行时。你可以通过从启动列表中移除 worker 条目来禁用任何 worker。你可以通过写一个注册了相同函数 ID 的替代品来替换任何 worker。你可以通过向其 hook 主题添加订阅者来扩展任何 worker。hook-fanout::publish_collect 是每个 iii hook 构建于其上的通用机制。

文档在 iii.dev/docs。引擎在 github.com/iii-hq/iii。Worker 注册中心在 workers.iii.dev。运行时代码包在 github.com/iii-hq/workers/harness。

赌注

运行时不是你安装的东西。运行时是你的系统为了让一个 agent 持久化、安全、可观测地运行而必须做的一组工作。框架时代之所以将这些工作捆绑在一起,是因为底层没有任何东西给你组合它们的方式。

iii 的赌注是:一个原语——一个 worker 通过 WebSocket 连接到引擎并注册函数和触发器——小到足以分别吸收这每一项工作,并且由此产生的技术栈比任何框架都更有用,因为每一层都可以独立替换。

你不是"采用" iii 运行时。你安装你想要的 worker,编写你需要的 worker,最终获得一个完全匹配你系统形态的运行时。每一层相同的协议。每一次调用相同的追踪。从注册中心拿来的组件和你自行发布的组件,使用同样的 iii worker add

这就是当基座形态正确时,"构建你自己的 agent 运行时"该有的样子。选择 worker。编写缺失的。组合。运行时就是这种组合。

加入我们,一起构建现代世界所需的完美 agent 运行时:discord.gg/iiidev

iii 是开源的。从 iii.dev/docs 开始。运行时 worker 在 github.com/iii-hq/workers,引擎在 github.com/iii-hq/iii。

— Mike Piccolo, 创始人兼 CEO