Agent 工程:Harness Adapter 的设计与实现
到 2026 年 H2,Coding Agent / Agent Runtime 已经百花齐放,社区也开始关注另一条路线:Harness Adapter(也叫 Meta Harness)。为啥要做,原因有二:
- 用户对 Runtime 各有所好,不会只选一家;
- 各家 Runtime 往往针对自家模型做过特调,只有“自家 Runtime + 自家 Harness”才能发挥完整能力。比如 Claude Code + Claude、Codex + GPT、Kimi CLI + Kimi。2026 年初,Cursor 的博客和 Kimi K3 的 limitation 都提到过类似现象。
当一个平台需要同时托管多种 Agent Runtime——有的用 Python 写,有的用 Rust 写,还有的是第三方 CLI——怎样让上层业务只面对一套协议、一套状态机和一套可观测语义?
社区在 26 年也有做过很多类似的产品和项目,比如 omini-agent(databrick 做的)、multica、raft.build 等等。
本文分享我在我们产品是如何做 Harness Adapter 的设计与实现。原文篇幅较多,这里使用 5.6-Sol & Opus5 一起压缩。
一、Harness Adapter 要解决的工程问题
1.1 每接一个 Runtime,就长一条私有链路
先看没有 Adapter 的情况。
Worker 启动时,要把某个 Runtime 的 Executor 和 Controller 注册进 Registry。Agent 绑定环境后,Worker 订阅 Agent 的增删改事件,把当前状态拼成“期望状态”;Controller 再据此创建 K8s 资源:Namespace、API key Secret、工作区 PVC、Headless Service、PDB、各类凭据 Secret,最后 apply 一个 StatefulSet。
随后,平台还要同步技能、资源和 MCP 配置,必要时调用 Runtime 私有的 reload 接口。真正执行时,Executor POST 私有的 /v1/runs,再打开私有 SSE,把私有事件翻译成平台事件写库。
这条链路能跑,但从入口到出口都是私有的:私有 HTTP、私有 SSE、私有工具返回、私有产物字段。Artifact 是最典型的例子:Runtime 自己发现并上传产物,再把产物 ID 塞进 end_turn / result 的私有参数。Worker 只能解析这个字段。接入第二种 Runtime 时,这段逻辑要再写一份;接第三种,还要再写一份。
追踪也会断链:Worker 建一个 Trace,Runtime 网关再建一个,Runtime 插件又建一个。根 Span 的所有权被拆散,跨进程传播只能依赖 Runtime 的定制代码。插件挂了,链路断;插件正常,根 Span 又可能重复。
因此,“再写一个 Provider”不是答案。需要先划清公共能力、原生能力和平台补齐能力的边界。
1.2 通用协议为什么不够用
最自然的想法是:业界已经有通用的 Agent 客户端协议(下文简称 ACP),让所有 Runtime 都说 ACP,Adapter 只消费协议不就行了?
我们调研过协议本身、stdio / WebSocket 桥、开源 daemon 型接入方案,以及某些 SDK 提供的 Harness 抽象。结论是:ACP 可以作为进程内的数据面语言,但撑不起平台级的完整诉求。
缺口主要有五个:
- 没有 conversation history 入参。 Prompt 没有标准的完整历史字段,只能让 Runtime 在本地维护 Session 状态,或者把整段历史塞回 Prompt。前者让实例变成有状态实例,后者只是文本层 workaround。
- 没有 Session / Run 级 system prompt。 协议只有实例级配置,单次对话的附加指令只能混进用户消息或自定义 meta。
- Remote 模式不成熟。 生态实现大多基于本地 stdio;Worker 要远程调用别处的实例,仍要自己加桥或 sidecar,而这层并不标准。
- 没有 tracing 传播语义。 协议没有统一 trace 字段,也没定义跨进程上下文传播方式。
- 没有通用插件机制。 安全审计、追踪注入和工具策略都缺少承载位置。
现有桥接方案的共同代价,是为了统一而压扁原生能力。例如:Prompt 只支持纯文本,多模态直接报错;内置审批被改成“全部允许”;原生工具只暴露少数几个;手动上下文压缩不支持;宿主工具甚至要走“生成脚本 → 提示模型用 bash 执行 → 桥再包装为 tool-call”的绕路。它们能用,但不是干净的函数调用路径。
最终的架构选择是:不把 ACP 当作唯一事实源。ACP 只负责 Adapter 与子进程之间的数据面;history、Run 级指令、追踪传播、安全插件和产物能力,由平台侧 Adapter 显式补齐。
二、四个所有权层的切法
架构里最贵的不是代码,而是所有权不清。一旦出现第二套调度器、第二套状态机或第二个协议事实源,后面的偶发 bug 都会变成“到底谁说了算”。
因此,整套架构只设四层,并把每层边界写死:
+-------------------------------------------------------------+
| L1 控制面 + Worker |
| 环境/Agent/不可变发布版本/Session/Run/Message/审批/终态 |
| 选 Provider、持 Session lease、持 Run fence、事件落库 |
+----------------------------+--------------------------------+
| Prepare
v
+-------------------------------------------------------------+
| L2 Session Sandbox 管理层 |
| 按 <kind+租户+工作区+Agent+Session> 管计算实例 |
| PVC / 工作区投影 / 镜像认证 / 凭据保险箱 / 出网策略 / 就绪校验 |
+----------------------------+--------------------------------+
| Sandbox API
v
+-------------------------------------------------------------+
| L3 Runtime Adapter(跑在沙箱主容器里,:8765) |
| HTTP/SSE 入口、并发准入、ACP 生命周期、事件归一化 |
| 审批、取消、产物扫描上传、清理 |
+----------------------------+--------------------------------+
| stdio JSON-RPC
v
+-------------------------------------------------------------+
| L4 Provider 包(每 Run 一个非 root 子进程) |
| 只管自己的认证、模型配置、MCP 投影、原生 trace、进程参数 |
+-------------------------------------------------------------+
边界可以压缩成三句话:
- L1 拥有平台事实,不碰子进程细节。
- L2 是计算与存储生命周期的唯一所有者。 Provider 不得另开 StatefulSet 路径。
- L3 拥有协议与进程细节,不拥有平台终态。
L1 和 L3 分别依赖一个独立发布的共享协议包,请求结构、事件结构和版本号都在其中;两边都不能 import 对方的 internal/**。这条约束防的是隐性耦合:Adapter 一旦偷读 Engine 内部枚举,Engine 升级时就可能直接炸掉。
Provider 包只放差异:Runtime 如何鉴权、模型配置长什么样、MCP 如何投影、原生 trace 从哪里读取、启动参数如何拼接。公共调度、状态和幂等语义一律不复制。判断标准很简单:如果一段逻辑在三个 Provider 包里几乎一样,它就不该属于 Provider。
三、运行时拓扑:从常驻 Pod 到 Session Sandbox
3.1 把 Pod 当沙箱用的旧账
托管 Runtime 最早采用“短进程 + Session 隔离 + 多实例”:一个 Agent 对应一组常驻 Pod,多副本共享同一份工作区存储。
看起来现代,实质上是在把 Pod 当沙箱用,并留下两笔必付的账:
- 多副本同时挂载同一工作区,存储必须支持 RWX(ReadWriteMany),而 RWX 在许多环境里都是运维负担;
- 没有对话时 Pod 仍然常驻,持续消耗资源。
新架构改成按需分配:Agent 创建时不创建沙箱;第一次 Run 进入 Prepare 才申请运行环境;Run 结束后也不立刻删除,超过空闲 TTL 再回收计算。
3.2 计算可回收,工作区要保留
拓扑设计最关键的一刀,是把“计算”和“存储”拆成生命周期不同的两类资源。
可以把 Session Sandbox 理解成工位,把 Session PVC 理解成工位下的抽屉。计算可以因空闲、指纹变化或故障被回收重建,但工作区仍留在原 PVC。下次执行时,只需申请新工位,再挂回原来的抽屉。
Session Key = <runtime-kind>:session:<租户>:<工作区>:<agentID>:<sessionID>
|
+---------------+----------------+
| |
可替换的计算资源 持久的存储资源
Sandbox 主容器 / sidecar Session RWO PVC
共享 emptyDir / Run 级目录 工作区 / 持久 venv
ACP 子进程 跨 Run 保留的文件状态
| |
空闲 TTL / 指纹变化 / 就绪失败 Stop 保留,Delete 才删
→ 删除并重建,不影响 PVC
复用维度锁定到 Session 后,同一份存储不会被多个副本同时挂载,PVC 用 RWO(ReadWriteOnce)就够了,RWX 的历史包袱随之消失。
Stop 和 Delete 的语义也更清晰:
- Stop:Agent 不再接收新执行,删除活动和空闲计算实例,但保留 PVC;Resume 后的下一次 Run 重新申请沙箱并挂载原 PVC。
- Delete:分阶段删除。先删沙箱和非锚点资源,落 releasing 状态;再删 Session PVC;最后删凭据锚点。每一步都必须幂等。
3.3 复用维度带来的三笔账
这个方案不是免费的,至少要正面接受三笔成本。
- 复用维度过粗,会砍掉水平扩展。 如果按 user + agent + agent_version 复用 Pod,同一 Agent 只能落到一个 Pod,并发上限会被单 Pod 的 Adapter 能力钉死。后来把复用维度收紧到 Session,就是为了让 Session 之间天然并行。
- 托管沙箱的常驻内存更高。 纯工具沙箱只承担镜像、依赖和工具进程;托管沙箱还要常驻 Adapter 和控制面进程。这是统一协议的直接成本。
- 冷启动延迟不可忽略。 某些 Runtime 启动时要下载并解析 3.7 MB 的模型元数据。填掉这类坑后,短进程模式每 Run 仍约有 1 秒准备成本;常驻模式首个 Run 约 1 秒,后续可降到 0.1 秒。Rust Runtime 的短进程启动则可能更快。
语言和启动路径的差异,会直接决定“每 Run 一进程”是否可接受。这个决策不能靠架构偏好,必须拿实测数据做。
四、一次 Run 的完整链路
这是全篇最重要的一段。下面保留完整时序,再拆出四个关键语义。
4.1 Lease + fence:谁有权写状态
第 04 步和第 16 步是一对。
同一 Session 被映射到同一条有序执行车道。Worker 写任何 Run 状态前,必须先拿到 Session lease;lease 被占用或出现临时错误时,返回可重试、不 ACK,等待队列重投。
拿到 lease 后,Worker 通过 CAS 把 pending 推进到 running,同时写入 fence generation。Lease 可能因网络抖动或 GC 停顿丢失,但旧 owner 仍可能存活并继续写事件。Fence 就是写入权凭证:lease 丢失后,事件 sink 被标记为 stale,执行上下文被取消,旧 owner 的后续写入全部被拒绝。
CAS claim miss 不是错误,它只说明 Run 已被其他 Worker 处理或已经进入终态。此时幂等 ACK,并尝试补齐“已持久化但尚未投影给客户端”的终态即可。
4.2 Prepare:拿到一个可信 endpoint
第 06 到第 10 步不只是“起一个 Pod”,而是一条完整的信任建立链:
- 校验身份和活性。 要求已持久化的非零 Session ID 与完整绑定信息;锁住 Agent 和 Session;创建前后两次确认 Agent 仍为 active。
- 生成计算定义。 Provider Builder 生成镜像、环境变量、资源 request / limit、RWO PVC、共享卷、sidecar、探针、能力集和 Adapter bearer。
- 验证凭据与工作区。 只为匹配仓库的镜像生成 pull Secret;认证不匹配时,在调用沙箱平台前失败。工作区同步采用 staging、完整性校验和原子发布。
- 创建或复用沙箱。 出网策略和凭据代理进入创建参数及计算指纹;同一 Session key 只保留一个当前有效实例;Adapter
/readyz与 sidecar/healthz/ready必须同时通过。 - 回读并验证实际状态。 就绪后回读出网策略,确认 DNS + nft 规则完整生效且与请求一致;再校验 endpoint 对应的 Pod、Session、Provider、release 和 8765 端口。任一步无法证明,就删除计算实例并 fail closed。
这里有两个关键设计点。
第一,Worker 直连 Pod IP,不经过沙箱平台转发。 少一跳,也少一个故障域;代价是 Worker 必须自己完成 endpoint 归属校验。
第二,工作区模板和 Session 工作区不是同一个东西。 模板来自控制面绑定和外部资产服务,由 sidecar 下载,在 staging 中校验后原子发布;失败时保留上一份可恢复快照。Session 工作区则位于 PVC,跨 Run 保留。
还有一个容易踩的细节:需要可靠文件锁的状态,例如 SQLite / WAL / mmap,不要放进网络存储上的 Session 工作区,而应写入 Run 级 tmp 目录。
4.3 Create Run 与 SSE:严格序号
第 11 到第 15 步只有三组规则,但每一组都直接影响正确性。
先写路由,再执行。 Provider kind 和 endpoint 先落库;拿到 provider run id 后,再把 run id 与 endpoint 一起持久化。如果持久化失败,必须主动向 Adapter 发 stop,否则会留下孤儿执行。
幂等键就是 run_id。 POST /v1/runs 的 Idempotency-Key 必须等于规范化的 run_id。同一个 ID、同一请求体复用结果;同一个 ID、不同请求体返回 409。
SSE 做三重校验。 版本必须受支持;方法必须在 Adapter 允许生产的白名单中;seq 必须严格等于上一帧加一。版本错误、未知方法、序号跳号或重复、非法 JSON、EOF 前没有 run/completed,都要把当前 Run 判为 Provider 失败并执行有界清理。这里不能“跳过坏帧继续”,因为事件流本身就是状态推导的输入。
公共协议里存在某个方法,不等于 Adapter 有权生产它。例如产物更新由平台合成:Adapter 只上报上传结果,平台再根据终态和版本序号生成统一事件。同一事实不能有两个作者。
4.4 终态唯一性
Adapter 返回成功,只是 Provider 的一份输入。平台终态必须由 Run 状态机 + owner fence + 终态 CAS 唯一产出。
因此,迟到终态、重复终态和旧 owner 终态都会被 fence 拒绝。一个 Run,在平台上有且仅有一个终态。
五、Adapter 内部:每 Run 一个进程
5.1 Runner 的固定顺序
Adapter 内部分为四层:
- HTTP server:鉴权、幂等、并发准入、SSE,以及审批和停止入口;
- 通用 ACP Runner:进程隔离、事件归一化和产物处理;
- Provider:校验、进程配置、认证、MCP 和原生追踪;
- 启动装配:合并 CLI / env / config,选择 executor。
边界同样明确:HTTP server 不知道子进程细节,Provider 不拥有幂等和终态。
每个 Run 都按固定顺序执行:
预留身份槽位(uid/gid/进程组)
-> 创建 Run 级 home / workspace / input / output / tmp
-> 物化附件、记忆、MCP、Provider 配置
-> 启动非 root 子进程
-> ACP initialize
-> ACP session/new
-> ACP session/prompt
-> 归一化回调事件,等待 prompt 结果
-> 静默化整棵进程树(quiesce)
-> 扫描并上传产物
-> 发出唯一终态
-> 清理 Run 目录,释放身份槽位
5.2 隔离靠身份,不靠信任
多租户执行用户代码时,隔离必须落在可验证的身份边界上。
Root supervisor 为并发 Run 分配不同的非 root UID、独立进程组、独立 HOME 和 Run 目录。同一 Session 内需要共享持久文件时,使用独立补充 GID + setgid 工作区,而不是放宽目录权限。
子进程只继承白名单环境变量。平台保留变量——内部前缀、HOME、PATH、代理和模型凭据——不能被用户配置覆盖。否则,用户可能通过环境变量把模型请求导向自己的服务。
取消采用有界升级:先 ACP cancel,关闭 stdin,再 terminate,最后 SIGKILL。Linux 上优先使用 pidfd 绑定已经观测到的进程实例,避免因 PID 复用误杀。
身份槽位只有在三个条件都满足后才能复用:进程树确认终止、产物文件稳定、文件归属校验通过。清理可以重试;永久失败或无法证明干净时,直接 poison 槽位,必要时 poison readiness。宁可降低实例容量,也不能把可能残留的进程和文件交给下一个 Run。
5.3 事件归一化:不猜语义
事件归一化只映射已经观测到的事实:
- 消息增量映射为 agent message 的 start / delta,完整文本由终态结果携带,不重复发送 completed;
- 思考增量映射为 reasoning 的 item / delta / completed;
- 工具调用映射为 tool 的 start / delta / completed,输入输出在出站前统一脱敏、规范化和限长;
- 计划事件映射到保留命名空间的 todo 快照,不占普通工具预算;
- 权限请求映射为稳定审批 ID,Adapter 内保留原始 option ID 供回传。
停止原因统一为 completed、failed、cancelled 三种。遇到未知停止原因、尚未闭合的工具或语义模糊的工具更新,一律明确失败,不猜生命周期。
协议中可选的 messageId 目前被有意忽略,因此这套实现不声称支持消息替换或 latest-wins 纠错。能力边界必须写清楚,不能向上层承诺尚未实现的语义。
5.4 单进程,还是每 Run 一进程
数字很残酷:每 Run 启动独立进程,单 Run 内存约 200 MB;复用常驻进程,单 Run 增量约 1.6 MB,相差两个数量级。
最终仍然选择每 Run 一个独立进程,因为它换回四项确定收益:
- Session 目录具备进程级隔离;
- 提高并发不需要二次开发上游 Runtime;
- Run 结束后的数据清理边界明确;
- 单个进程崩溃不会拖垮其他 Run。
本质上,这是用内存和约 1 秒启动成本,购买“隔离性 + 不改上游 + 最小崩溃域”。对于托管用户代码的平台,这笔成本应该明牌计算,而不是藏在抽象后面。
不同 Provider 可以有不同并发准入策略:按 cgroup 有效内存推导槽位、固定为 1,或者镜像预设 16。但无论 Adapter 有多少槽位,平台层都必须保证同一 Session 严格串行;槽位只控制不同 Session 之间的并行度。
六、三个横切能力的收口
统一 Adapter 的主要价值,不是少写几行 HTTP,而是把追踪、安全和产物三项横切能力从各个 Runtime 内部收回公共层。
6.1 Tracing:根 Span 归 Adapter,叶子归插件
规则只有两句:
- Adapter 从可信上层上下文创建唯一语义根
runtime_request -> runtime_run,并负责结束; - Runtime 原生插件只贡献受控叶子 Span,例如 LLM、工具、审批、技能、子 Agent 和上下文压缩。
插件不能再创建一组 request / run 根,也不能持有导出凭据。原生模式与 Adapter fallback 模式必须互斥;fallback 只能根据公共事件生成 Runtime 和 Tool Span,不能推测 Runtime 内部语义。
插件 journal / buffer 是“子进程可写输入”。导出前必须校验版本、序号、身份、权限、拓扑和字段上限,非法时整体 fail closed。Span 不记录密钥、完整 Prompt、完整工具参数和文件内容;追踪故障也不能阻塞 Run。
一个更具体的问题是追踪 Header 的传播。平台需要向模型网关携带约 18 个 Header,包括 traceparent、tracestate、baggage,以及租户、工作区、用户、Agent、Session 和消息维度。Header 每个 Run 都不同,但常驻进程中的插件只注册一次。
解决方法是让插件注册一个可修改请求的 LLM 中间件。中间件用 session_id 查询 Runner 内存中的 registry:
Runner
|
|-- session/new --> 返回 session-a
|
|-- Registry.Put(session-a, Run A 的 18 个 Header)
|
'-- session/prompt(session-a)
|
v
运行时把 session_id 当作 task_id 传给 Agent Loop
|
v
每次调模型前执行 llm_request 中间件
|
v
Registry.Get(session-a) -> 合并进 extra_headers
|
v
Provider Client 发出 HTTP 请求 -> 模型网关拿到 Run A 的完整 Header
映射关系:
session-a -> run-A 的 Header 集合
session-b -> run-B 的 Header 集合
session-c -> run-C 的 Header 集合
Run 结束:defer Registry.Delete(session-a)
这样做有三个收益:插件只注册一次;同一 Run 中的多次 LLM 请求都能拿到正确 Header;Provider 名或 base_url 不匹配时,中间件直接返回,避免内部 Header 泄漏到其他服务商。允许注入的 Header 也严格限制在白名单内。
6.2 安全:加载失败就不 Ready
重构前,安全开关、插件配置和凭据由上层直接写入 Runtime 配置;插件虽然跑在 Runtime 进程里,却没有统一的装载和生命周期入口。允许、阻断和降级结果通过私有链路返回;远端审核异常甚至会静默 fail-open。
重构后,Adapter 把插件包和只读配置投影到 Run 级 HOME;安全开启时,校验或加载失败会让整个 Runtime 无法 Ready。 “安全插件没装上,但服务照常运行”这个危险状态因此被直接消灭。
审核点按职责拆开:
pre_llm_call:审核模型输入;pre_tool_call:在执行前阻断;post_tool_call:缓存执行上下文;post_llm_call:只做输出审计。
另有独立插件阻断技能目录写操作。远端审核异常仍可按既定策略重试和 fail-open,但必须通过指标与告警显式暴露。Fail-open 本身是一种策略;静默 fail-open 才是事故。
凭据也必须分域:Adapter bearer、记忆 MCP key、产物代理 key、工具检索 key 和 Provider 凭据互不复用。用户 Agent 的外部 API 凭据通过凭据保险箱和代理投影,真实值不进入普通环境变量、metadata、日志或公共事件。代理转发时重建上游 Header,拒绝透传 Authorization、Cookie、forwarded 和逐跳头。
6.3 Artifact:从 Runtime 内部搬到 Adapter
过去,Runtime 负责产物发现和上传,再把产物 ID 写入私有结果字段。Worker 必须解析私有字段,文件生成、上传和终态提交也被绑在 Runtime 的进程生命周期里。
收口后,流程变成:
输入侧:Adapter 通过内部产物 MCP 把附件下载进 Run 工作区
-> 路径校验 + 去重 + 配额控制
-> 附件必须落在 attachments 根目录下
执行侧:子进程只把最终用户文件写进 output 目录,临时文件写 tmp
其他目录一律不发布
输出侧:ACP 结束 -> 先终止全部写进程 -> 再扫描 output
只接受普通文件
拒绝:软链、设备文件、Socket、路径穿越
配额:最多 64 个文件 / 单文件 64 MiB / 总量 256 MiB
上传前复查文件状态(防"扫描后被替换")
-> 批量上传,返回结构化产物 ID
-> 任一上传失败:Run 不提交成功终态,也不发布部分产物
这里有两条不能放松的顺序约束。
第一,先终止写进程,再扫描。 顺序反过来,扫描和上传看到的可能不是同一份内容,而且问题往往只在高负载下偶发。
第二,任一上传失败,整体不能成功。 成功终态必须同时满足:进程静默、文件稳定、全部上传成功。产物半发布比明确失败更糟。
需要区分“上传失败”和“通知投影延迟”:上传本身失败,Run 不得成功;全部上传成功后,如果终态前的产物通知屏障超时,可以在终态中携带显式“不完整”标记,再由后台复用原消息 ID 幂等补发通知。这样既不伪造成功产物,也不会无限阻塞终态。
收口后的直接收益是:模型不需要主动调用“上传工具”,平台也能稳定返回产物 ID。
七、审批、取消与恢复
审批、取消和恢复都是带外动作。它们不沿正常 Run 数据流前进,因此必须依赖同一份持久化路由与状态事实。
7.1 审批:稳定 ID + 同一 endpoint 权威
流程如下:子进程发出权限请求;Adapter 生成 <run-id>:approval:<n> 形式的稳定 ID;Worker 持久化审批和路由;网关向客户端展示公开选项;用户提交决定;Worker 重新验证 endpoint 仍属于当前 Session、release 和 Sandbox,再带 bearer 调用 Adapter;Adapter 对相同决定幂等去重,对冲突决定返回 409,对过期请求直接拒绝,并把公开选项映射回原始 option。
三个细节需要写死:
- “本次允许”只作用于当前 Run;“本会话允许”由平台持久化成跨 Run grant,下一个 Run 通过授权快照恢复;
- 审批超时由 Adapter 收敛,超时后停止执行并带明确原因结束,不能让 Run 无限挂起;
- 审批与 Run 共用同一个 endpoint 权威,不能在审批时重新服务发现一个“可达地址”。
7.2 取消:状态先落地,再发命令
平台必须先把 Run 推进到 cancelling,再向 Adapter 发送 stop。Stop 请求成功不等于 Run 已取消;如果先发命令后改状态,中间任何一次崩溃都会让状态与现实脱节。
还要处理一个竞态:取消可能发生在 provider run 具备路由信息之前。Executor 不能丢弃这个请求,而要记录 cancel intent;Create 完成并拿到路由后,立即执行清理。
Adapter 侧依次尝试 ACP stop、关闭 stdin、terminate、kill。最终由 Provider 终态或平台取消 owner 产出唯一 cancelled 终态。
Drain 也遵循相同思路:先停止准入,再等待活动 Run;超时后强制产生取消终态。Drain、stop、HTTP shutdown 和 telemetry flush 各自有界。
Adapter Run 超时、Worker lease、业务 Run 预算和恢复清扫器是四种不同保证,不能互相替代。没有心跳等活性证据时,清扫器不能只按“stale 了多久”回收长任务,否则正常运行数小时的任务也会被误杀。
7.3 Resume:不创建第二个 Run
恢复依赖持久化事件时间线、cursor 和可选内存流快照,从客户端 LastEventID 之后继续投影。
Resume 只恢复同一个 Run 的客户端视图,不向 Adapter 创建第二个 Run,也不重放已经确认的 cursor。 内存快照只补齐正在生成的消息文本;已完成消息和 Run 状态始终以数据库为权威。
八、协议版本与升级顺序
共享协议包有明确版本,并保留上一兼容版本。新增语义——例如更细粒度的工具调用增量事件——必须配合固定发布顺序:
升级:先升 Engine / Worker -> 再升 Adapter
(新 Engine 同时接受 当前版 与 上一版 Adapter)
回滚:先回滚 Adapter -> 确认不再产生新版本专属事件 -> 再回滚 Engine
版本兼容不能靠“忽略未知字段、忽略未知方法”实现。旧 Engine 不承诺理解新 Adapter 的事件;如果先升级 Adapter,新事件会被旧 Engine 的严格校验拒绝,Run 将批量失败。
既然选择严格校验来保证状态正确,就必须用发布顺序换取兼容窗口。
请求侧也有硬边界:所有 /v1/* 请求都需要 bearer,并使用常量时间比较;健康检查不鉴权;请求体最大 4 MiB;请求头、响应体和超时均有限制;HTTP 重定向直接拒绝。Adapter 端口固定,探针必须同时覆盖 Adapter 和 sidecar 的就绪状态。
九、Fail closed 的底线
底层原则只有一句:无法证明安全或正确时,拒绝继续;只影响观测时,允许有界降级。
| 场景 | 处理方式 |
|---|---|
| Lease 被占或临时错误 | 不 claim、不 ACK,等待重投,不计入 poison 预算 |
| 绑定、版本或配置不完整 | 在任何 Provider I/O 前失败,不创建沙箱 |
| PVC、出网策略或凭据校验失败 | 保留证据或删除当前计算实例,禁止无策略运行 |
| Endpoint 不属于当前 Pod / Session / release | 拒绝命令,不回退到任意可达地址 |
| SSE 非法或清理无法证明完成 | Run 判失败并清理;必要时 poison 槽位或 readiness |
有界降级只用于不改变业务正确性的路径:Tracing fallback 默认 fail-open,只损失遥测;取消或失败场景下的产物收集有时间上限,超时追加诊断,但不阻塞唯一终态和容量释放。
对外错误合同也要稳定:Reason 是可扩展的机器合同,Message 是有界且不稳定的诊断文本,调用方不能解析 Message 做流程分支。
指标 label 只使用低基数的 provider、stage、result 和 reason。Agent、Session、Run、工具参数和凭据都不能进入 label,否则监控系统会先被高基数拖垮。
运维定位按阶段展开:Agent 生命周期 → 派发与租约 → 绑定物化 → 沙箱与 PVC → 就绪、策略与凭据 → Adapter 准入 → 子进程与模型 → 事件与终态 → 产物与追踪 → 清理。不要用一个笼统的“Runtime status”掩盖真实故障层。
十、总结
到此为止,我们做了一套 acp based 的 Harness Adapter 结构,每次接入一个 Agent Runtime,我们只需要做:
- Agent Runtime 能力实验 & base 镜像构建
- Agent Runtime ACP Adapter & 镜像构建
- 业务平台集成
这个过程, 平台负责 Harness 的抽象定义(Skill,MCP,System Prompt,Resource,Memory,Model Provide, etc),而 ACP Adapter 负责翻译 & 物化 到 Harness 的具体实现(说的再大白话些,比如把 Skill 写到哪个目录,以及在配置文件如何定义)
从而实现,同一套架构和约束,不同的 Runtime Kernel。
Comments