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 的钱花在哪儿,取决于它有没有现成的协议库。

如果要给一句话:实现别人的协议,大部分时间花在「想清楚它到底要什么」,而不是写代码。