uhp-cj 实现笔记
协议不是文档,是一堆互相打架的条款
这篇讲的是怎么实现一个别人定的协议。
客户端接一个 Agent 很容易;难的是让一个服务端「说得对、答得对」,
而且这份「对」不是你说了算——有一套别人的检查在等着你。
一、四个 harness,四个接口
opencode、dsh、pi、claude-code——这四个东西都能干活,而且都能通过命令行或 HTTP 被驱动。但它们各自的接口不同:opencode 有 opencode serve 的 HTTP API,dsh 和 pi 走 ACP,claude-code 又有自己的一套。
如果你的产品要同时用它们(或者只是想让用户自己挑一个),就要写四份适配、维护四套错误处理、四套流式解析。换一个新 harness,再来一遍。
这就是 UHP 想解决的事:定义一套「怎么通过 HTTP 驱动一个完整 agent harness」的标准,让客户端只面对一个接口。
协议本身是公开的,unifiedharnessprotocol.org
,当前版本 2026-10-19。本文讲它的仓颉实现 uhp-cj:一份规格从文档变成可运行的服务端,中间要经过什么。
二、那 A2A 呢
看到「Agent 协议」这几个字,第一个问题是:A2A 不是也在做这事吗?
不是。它们连的是系统里不同的两段。
UHP 的生态文档 把几个协议各自的位置列清楚了(这份是信息性的,不属于规范本身):
| 协议 | 连谁和谁 | 边界 |
|---|---|---|
| MCP | agent ↔ 工具、数据 | agent 能调什么 |
| A2A | agent ↔ agent | 发现能力、委派工作、交换结果 |
| ACP | 编辑器 ↔ coding agent | 一次编码会话的交互 |
| AG-UI | agent ↔ 前端 | 展示与用户交互 |
| UHP | 产品 ↔ 完整 harness | 任务、会话、流式、文件、产物 |
A2A 那段原文是这么写的(译):
A2A 连接 agent,让它们能发现彼此的能力、委派工作、交换结果。
它的边界是 agent 之间的协作,而不是一个产品去挑选并驱动一个 harness。
差别在这句「挑选并驱动」上。A2A 假设两端都是 agent,它们协商着干活;UHP 假设一端是产品,另一端是一个可以被指派任务、然后交回结果的盒子——盒子里面是怎么组织的,产品不管。
一个具体的例子
DeepSeek 在 10 月底公开了一个叫 Super-agent 的架构:Router / Worker 两层。上层 Router 做分解、分发、汇总、协调,下层多个 Worker 执行,各自上下文隔离,Worker 不直接跟人说话,只向 Router 汇报。
现在问一句:Router 和 Worker 之间该用什么?
如果按 A2A 来接,Router 面对的是 N 个 agent:它得知道每个 agent 能干哪类活、支持什么能力、怎么委派。这在 A2A 的假设里是合理的——因为 A2A 就是给「agent 之间协作」用的。
但 Router 真正需要的不是这个。它需要的是:
| Router 要什么 | UHP 里对应什么 |
|---|---|
| 派一个任务过去,拿回结果 | POST /v1/responses + 终态 |
| 任务得有状态:跑完、失败、取消、要人拍板 | 任务状态机(cancelled 是独立终态) |
| Worker 之间不能串味 | 会话隔离 + session_busy(同会话第二个请求 409) |
| 拿了什么产物 | 文件登记与下载 |
| 执行到一半要问人 | input_request + 下一轮续跑 |
| 这个 Worker 现在能不能用 | GET /v1/harnesses 与能力声明 |
这些全是「产品驱动 harness」要的东西,正好是 UHP 的协议面。
更关键的是最后一点:Worker 内部怎么组队,Router 不应该感知。Super-agent 的 Worker 内部还可以再开 Subagent、或者本身就是一个 Agent Team——官方说法是这些对 Router 层保持透明。
UHP 的措辞几乎一样:客户端只管派任务,harness 自己拥有 agent 循环和工具。
所以这一层用 UHP,是因为它的边界刚好画在 Router 和 Worker 之间:
Router 是「产品」的角色,Worker 是「harness」的角色。
A2A 的边界在别处——它在 Worker 之间、或者跨组织的 agent 协作那一段才最贴。
不是取代
这两个协议不冲突,可以叠。UHP 自己的例子就是:一个 ACP 适配器可以坐在 UHP 边界后面——UHP 服务端去拉起 agent 自己的 ACP 服务器。同理,需要跨组织协作时,那些 agent 之间可以用 A2A 说话,而产品侧仍然通过 UHP 驱动自己的 harness。
选哪个取决于你画的那条线在系统里的位置,不是哪个协议更新。
三、协议规定了三件事,我只做两件
UHP 的规格里写清了它管什么:
| 管不管 | 内容 |
|---|---|
| 管 | 协议面:对象、端点、状态机、能力、错误模型、事件流 |
| 管 | 服务端骨架:谁能做什么、什么时候拒绝、什么状态不可回退 |
| 不管 | 执行:真正的 agent 循环、模型调用、工具执行、沙箱 |
前两件是「协议语义」,第三件是「干活」。uhp-cj 只做前两件,第三件通过一个接口接进来:
public interface HarnessDriver {
func base(): String
func baseLabel(): String
func models(): Array<Model>
func capabilities():
HarnessCapabilities
func run(ctx: TurnContext,
sink: TurnSink,
cancel: CancelToken): TurnOutcome
}
一句话说清边界:SDK 负责「说得对、答得对」,不负责「干得好」。
所以这个库里没有容器编排、没有模型 SDK、没有 HTTP 客户端去调某个 agent——那些是消费方的事。run 是唯一的接缝,实现它就把任意 agent 接到了协议上。
规模
| 成员 | 代码 | 文件 | 测试 | 依赖 |
|---|---|---|---|---|
uhp(SDK) |
14337 行 | 38 | 142 | 零第三方 |
uhp-opencode |
970 行 | 7 | 11 | 仅 uhp |
uhp-acp |
970 行 | 5 | 14 | uhp + acp + jsonrpc + jsonvalue |
uhp-server(成品) |
419 行 | 2 | 2 | uhp + 适配器 |
SDK 的 [dependencies] 是空的——只依赖 stdx。这是个有意的取舍:协议实现不该把第三方库的版本问题带给下游。
两个适配器恰好都是 970 行,但构成完全相反(后面会讲)。而最后的成品服务只有 419 行——这个数字本身说明分包分对了:协议、适配、装配三件事被切开了,最后拼起来的那层就薄。
四、一个协议有几份来源
我以为「实现一个协议」= 读规格 → 写代码 → 对着规格检查。实际不是。
UHP 的内容分散在三份来源里,而它们并不总一致:
| 来源 | 权威范围 | 形态 |
|---|---|---|
| prose | 行为 | 13 章正文,讲「什么时候该怎样」 |
| openapi.yaml | 结构 | 端点、字段、状态码 |
| schema.json | 交叉校验 | 只有 $defs,没有端点信封 |
外加第四样东西:官方一致性套件。它不是来源,是判据——而且它是唯一算数的判据。
于是问题变成:三份来源打架的时候听谁的?
例子一:Event 到底是不是联合类型
prose 说事件是按 type 区分的 discriminated union。听起来很标准。
但 openapi 生成的制品里,Event.type 只是一个自由字符串 + 18 个 example,没有 oneOf。schema 也就无法约束它。
那按哪个写?
- 按 prose 写成封闭联合 → 将来协议加一种事件,你的实现直接解析失败
- 按制品写成自由字符串 → 又对不上 prose 的语义
最后的手写联合是 18 个变体 + 一个 Unknown 兜底。未知事件必须能被解析、能原样转发,不能崩。
例子二:一个头,写在哪些响应上
UHP-Version 头,openapi 只声明在成功响应上。
prose 说:每个响应,含错误响应。
听谁的?答案在套件里——V-01 检查项就在测错误响应有没有这个头。
所以:按 prose,writeJson 和 writeError 统一补头。
这个例子说明了一件事:三份来源打架时,真正的裁判是套件。prose 说得再清楚,套件不测就等于没有约束;openapi 没声明,不代表可以不写。
例子三:取消没有自己的事件
取消是个独立的终态,但事件类型是封闭集合——里面没有 response.cancelled。
所以要发 response.failed,然后靠 response.status = "cancelled" 表达「这是取消,不是失败」。
状态是权威,事件类型只是外壳。
五、30 条判定
上面三个例子不是特例。实现下来,这类需要「停下来想清楚听谁的」的地方有 30 条。
仓库里有一份 docs/spec-conflicts.md 专门记这个:
| 组 | 条数 | 内容 |
|---|---|---|
| A · 结构类 | 6 | schema / openapi 表达不了的 |
| B · 行为类 | 13 | prose 要求,openapi/schema 缺失或相反 |
| C · 可选能力 | 11 | 插件、环境、记忆这些章节的细节 |
| D · 文档纪律 | 3 条 | 不算判定,是维护这张表本身的规矩 |
三组加起来 30 条。这份表有一条自我约束,我觉得比表本身更值得说:
每一条都要能被执行(有代码或测试指向它),否则只是注释。
于是每条都得写清三样:判定、落地位置、套件锚点。比如:
B5. Response 的 error/usage/store/
metadata/previous_response_id
必须写 null
冲突:schema 说可选;prose 要求键存在
判定:生成器 ALWAYS_EMIT_NULL 名单
落地:tools/gen_types.py
注意最后那个「套件锚点」——每一条都要指出哪一项检查在守它。指不出来的,就说明它其实没人验证,那迟早会退回去。
这个做法带来一个副作用:spec-conflicts.md 不是设计文档,是测试的反向索引。改代码前先查它,改完对着锚点跑那一项。
D 组那三条规矩里,第一条最容易被违反:skip 从不等于 pass。一致性报告里 conformant 要求 skip 数为 0,本仓三档都是零 skip。套件的检查项有依赖关系(前面挂了后面会 skip),所以「49 通过 1 跳过」看起来像 98% 通过,实际是有一项没跑。
三条影响行为的判定
「保留字段」要接受、忽略、可观测
请求里有 tools、include 这类字段,本实现不执行它们。但「不执行」不等于「当没看见」——
正确做法是三件事都做:接受请求、不执行、把忽略掉的字段名写进 metadata.ignored_fields。
只做前两件,客户端会以为自己的参数生效了。这是「静默失效」的另一种形态:你没做,但你也没说。
secret 的答案不能出现在任何客户端可读的位置
提问(ask_user)可能涉及密钥。答案只进 TurnContext.answers,input_items 里对 secret 类型的答案直接省略。
未 build 的环境必须在任务开始前拒绝
不能在跑了一半之后才发现环境没准备好——那时已经消耗了模型调用,而且状态机已经进入 running,拒绝起来很别扭。
六、一半的代码是类型,而类型是生成的
看一下 SDK 内部的分布:
| 包 | 行数 | 干什么 |
|---|---|---|
types |
7077 | 协议对象、编解码 |
server |
6381 | 引擎、路由、状态机、SSE |
client |
471 | 发现、协商、流式消费 |
transport |
400 | stdx.net.http 适配、SSE 解析 |
types 占了将近一半。这大概是协议实现的共性:大部分工作量是把协议说清楚,而不是让它动起来。
所以能不能生成?
能,但只能生成一部分。tools/gen_types.py 读 openapi 产出 90 个类 + 2 个枚举。剩下的必须手写:
| 手写什么 | 为什么生成不出来 |
|---|---|
UhpEvent 联合(18 变体) |
制品里没有 oneOf,schema 表达不了 |
| 13 个端点信封 | schema.json 只有 $defs,信封在 openapi 里是 inline |
ErrorEnvelope |
6 种 type、49 个 code、「哪种可重试」属于行为语义 |
Capabilities |
16 个布尔必须全写、缺省 false,Option<Bool> 会诱导出第三态 |
生成物有文件头标记,只允许生成器写。CI 里加了一道 --check:重新生成后逐字节比对,不一致就红。
这道闸门值不值得?值得——它挡住了「手工改了生成文件、下次生成被覆盖」这种最难查的问题。
依赖方向也是硬规则
types ← 不许 import 传输层
↑
transport ← 只有 IO
↑
client / server ← 协议语义
types 里出现一行 import stdx.net.http 就是错的。理由很实际:类型层要能被任何传输复用,而一旦它认识了 HTTP,将来加一种传输就得改它。
这条规则写在评审清单里,不是靠自觉。
七、104 项检查
回到那个「唯一算数的判据」。
一致性套件是官方仓库里的一个 Python 包,跑起来很简单:
uhp-conformance \
--base-url https://your-server \
--api-key "$UHP_API_KEY" \
--class full \
--json report.json
--class 有三档,累进:
| 档 | 检查项 | 覆盖什么 |
|---|---|---|
core |
41 | 发现、harness 列表、跑任务、取消、流式、错误 |
extended |
50 | core + 会话列举与并发 |
full |
104 | extended + 文件、插件、环境、记忆、分享 |
uhp-cj 的三份报告都在仓库里(docs/conformance/*.json):
core 41/41 conformant: true
extended 50/50 conformant: true
full 104/104 conformant: true
为什么这个数字值得看
因为规则写得很死。套件文档的原话(译):
通过这套件就是「UHP conformant」的定义。不是实现了端点,不是自我评估,也不是大部分通过。套件在这个仓库里,它通过 HTTP 跑在任意服务端上,任何人都能拿它去测任何人的实现。
三句话里有两句在堵后门:
- 「不是自我评估」——所以仓库里那份 JSON 不是我说了算,别人可以重跑
- 「任何人都能测任何人的」——所以「兼容 UHP」是个可以被证伪的主张
这对实现者是压力,但对使用者是好消息:你不需要相信我,你只需要跑一遍。
一个被拦下来的低级错误
为了跑一致性测试,需要一个「被测系统」——一个能起服务、能被套件打的样例程序。很自然地,我把它放在了 src/examples/ 里。
发版审核没过。原因是那个目录会进制品,而里面的代码在监听端口、驱动子进程。
改成独立项目(path 依赖 SDK)之后就好了,同时 SDK 制品也加了排除规则:只保留库源码 + README + LICENSE + cjpm.toml。
这不是「协议实现」的问题,是「发一个库」的问题:
测试用的程序不该跟着库一起发出去,尤其当它会开端口和起进程时。
八、接真实 harness:文档和实测不一样
协议实现完了,还得证明它真能驱动东西。两个适配器分别接了 opencode 和 ACP。
一个意外的对称
两个适配器都是 970 行,但钱花在了不同地方:
uhp-opencode |
uhp-acp |
|
|---|---|---|
| driver | 528 行 | 804 行 |
| 传输 | HTTP + SSE 自己写(379 行) | 复用 acp 包 |
| 依赖 | 仅 uhp | uhp + acp + jsonrpc + jsonvalue |
opencode 有 HTTP 文档但仓颉生态没有现成客户端,所以得自己写 HTTP 调用和 SSE 解析;ACP 有现成的 acp 包,省了传输,但协议回调更多,driver 就更长。
总成本差不多,只是分布在不同的地方。
文档说的,和实际跑的
ACP 规格只保证「会话可以共存」,不保证单连接内能并发跑回合。所以「支持并发」不是默认假设,得逐个实测。
实测下来是这样的(uhp-acp/docs/support.md,2026-11-11):
| harness | 多会话 | 回合并发 | session/close |
session/list |
|---|---|---|---|---|
opencode(opencode acp) |
✅ 实测 | ✅ 实测 | ✅ 实测 | ✅ 实测 |
DSH(dsh --profile acp) |
✅ 文档 | ✅ 文档 | ✅ 文档 | ✅ 文档 |
pi + pi-acp 0.1.8 |
✅ 实测 | ✅ 实测 | ✅ 实测 | ✅ 实测 |
opencode 的并发实测数据:两个任务交错执行,总耗时 4.6 秒,而不是串行的两倍。这才能说它真并发。
「文档」和「实测」是两列。 仓库里的规矩更硬:
没实测过的先当不支持。
所以代码里有个 sessionMode 开关:
| 模式 | 行为 | 什么时候用 |
|---|---|---|
Serial(默认) |
一个 driver 一次跑一路回合,并发调用排队 | 任何 ACP agent |
Parallel |
不同会话的回合真并发,同会话仍串行 | 已确认支持的 agent |
默认 Serial。要开 Parallel,你得先在表里证明它行。
为什么默认保守:并发跑错的风险不是变慢,是任务交错污染——
你会在别人的会话里看到自己任务的输出,而且很难复现。
顺带一个语言层面的坑
仓颉的 match 有个陷阱:
// 这样写,第一个分支永远命中
match (s) {
// 被当成变量绑定
case SOME_CONST => ...
case "other" => ...
}
public const String 在 case 里会被解释成变量绑定,不是常量比较。所以那段代码看起来在分派字符串,实际永远走第一个分支,而且不报错。
修法是:字符串判别一律用 if (s == SOME_CONST),match 只留给枚举和模式匹配。
这个坑的可怕之处在于——它不报错,只是悄悄走错分支。测试能抓到,但只有你真的测了那个分支才行。
九、uhp-server:从零件到成品
协议实现是「零件」,但没人会为了跑一个 Agent 去写装配代码。所以还需要一个成品:一个进程、一个端口,背后挂 N 个 harness。
uhp-server 只有 419 行,因为它把活都留给了 SDK:
| 它做 | 它不做 |
|---|---|
装配 Engine + UhpHttpServer、读 TOML、管 token、管 harness 生命周期 |
协议实现(在 SDK 里) |
按 kind 选适配器 |
agent 循环、模型调用(在 harness 自己的进程里) |
| 出部署产物:单二进制、Docker、配置样例 | 客户端逻辑(消费方自己持有) |
三种 kind,一条装配线
[[harnesses]]
id = "oc"
kind = "opencode"
base_url = "http://127.0.0.1:4096"
[[harnesses]]
id = "echo"
kind = "scripted"
reply = "ok"
kind = "acp" 会拉起子进程(dsh、pi、opencode acp 都是这一种),kind = "opencode" 连一个已经跑着的 opencode serve,kind = "scripted" 是自检用的假 harness。
scripted 看着像个玩具,实际是部署时最常用的一个:
部署验证不依赖任何凭据和模型。 改完配置、起服务、打一发 scripted 任务,能通就说明链路是好的。没有它,每次验证都要烧 token,而且模型不稳时你分不清是网络问题还是服务问题。
启动严,连接懒
两件事看起来矛盾,实际是分工:
| 环节 | 策略 | 为什么 |
|---|---|---|
| 读配置 | fail fast | 未知 kind、缺 command、重复 id、空 token 表 → 直接启动失败 |
| 连后端 | 惰性 | ACP 适配器到第一个任务才拉起子进程 |
启动就失败是好事,因为配置错误是最容易犯的错:你写错一个 kind,服务照常起来,然后第一个任务 500——你会在协议层找半天。
但连后端不能急。一个 harness 暂时没起,不该导致整个服务拒绝启动——否则重启顺序就成了运维问题。
会话回收是我的责任,因为规范没说
UHP 规范里没有 DELETE /v1/sessions/{id}。会话由服务端持有,按自己的策略回收。
所以 P0 的约定是:会话与响应都在内存,重启即丢。协议不要求持久化,客户端把 404 当成「新会话」处理。
但这里埋着一个后续要还的债:加会话 TTL 驱逐时,驱逐必须调 driver 的 closeSession()。不调的话,服务端认为会话没了,而 ACP 那边的会话还挂在 agent 进程里——跑久了就是泄漏。
一个「看不到日志」的坑
这个跟协议没关系,但值得记。
uhp-server 的启动日志是显式 flush 的:
func info(msg: String): Unit {
let out = getStdOut()
out.write(msg + "\n")
out.flush() // ← 这行不能省
}
原因:stdout 被重定向到文件或管道时(Docker、systemd、nohup)是块缓冲。进程被 SIGKILL 时缓冲区没刷,日志全丢——运维那边只能看到「服务没反应」,什么线索都没有。
跟协议层的「静默失效」是同一类问题:你以为它在正常工作,其实是它说了但你没收到。
P1:拆成插件进程,以及拆完的新成本
P0 是单进程,适配器是进程内对象。P1 想拆成 cordis-cj 托管的插件进程——进程隔离、热卸载、崩溃自愈,而协议面与配置语义不变。
为什么不一开始就拆?因为风险最大的部分是协议面,而适配器本身很薄:真正危险和重的 agent 已经在自己子进程里了。先把协议跑通,比先追求插件拓扑更划算。
拆的时候有个隐藏成本必须提前算:
run 的流式增量(text / reasoning / toolCall / diff)是高频事件。拆进程后这些要走 IPC,而 cordis-cj 的设计目标是中低频控制面。所以不能一个 token 一次 IPC,得按 20–50ms 或若干 KB 批量上行。
进程隔离不是免费的,它把「函数调用」换成了「消息」,而消息的粒度要重新设计。
成品反过来改零件
写 server 的过程暴露了 SDK 的三个缺口:
| 缺什么 | 为什么 server 需要它 |
|---|---|
生命周期接口 close() / closeSession() |
长跑要回收 harness 会话,现在 SPI 没有生命周期钩子 |
| 动态 harness 注册 | 插件装卸需要,Engine 的注册表现在是构造时固定的 |
| 客户端补齐 sessions / files / input_response | 消费方(星痕)接入需要 |
这三条都不是「协议没实现」,而是用起来才发现的形态问题。一致性套件测不出它们——套件只保证互操作,不保证好用。
十、边界与限制
诚实列一下这实现做不到什么:
| 限制 | 说明 |
|---|---|
| 不做执行 | 模型调用、工具执行、沙箱都在 driver 那边 |
| 不做容灾 | 状态在内存,重启丢进行中的任务 |
| 不做多进程 | P0 单进程;要横向扩展得自己加一层 |
harness_retirement 未实现 |
删 harness 时保留会话,套件接受 |
| 响应级幂等未实现 | harness_idempotency 有,idempotency(响应级)没有 |
input_history 未实现 |
input_items 只含客户端提交的,不含 secret 答案 |
最后三条不是「懒得做」,是协议允许不做——它们是可选能力,套件里有对应检查接受这个选择。
这是分级协议的好处:你可以只做 core 就说自己合规,
而不是「没做全就是不合规」。
自由被限制在哪儿
为了「任何人都能测」,协议必须把话说死。话说死了,实现就没有发挥空间。
比如记忆的 content 输入允许是字符串,但存储与输出永远是 parts 列表。你可能会想「那我存字符串也行吧」——不行,套件会打到。
这类地方一开始会觉得别扭。但换个角度:它把实现者的自由限制在了不影响互操作的部分,剩下的随你怎么做。
十一、小结
- 协议不是文档,是一堆互相打架的条款。三份来源(prose / openapi / schema)各有权威范围,冲突时听套件的。
- 30 条判定,每条都要有落地位置和套件锚点。指不出锚点的那条只是注释,迟早退回去。
- 一半代码是类型,而类型只能生成一部分。90 个类 + 2 个枚举能生成,事件联合和错误封套必须手写。
- 104 项检查是可被第三方重跑的。「兼容」这话由别人说才算数。
- 并发不是默认假设。ACP 只说会话能共存,能不能并发得逐个实测,实测前一律
Serial。 - 两个 970 行的适配器,成本分布相反。接一个新 agent 的钱花在哪儿,取决于它有没有现成的协议库。
如果要给一句话:实现别人的协议,大部分时间花在「想清楚它到底要什么」,而不是写代码。