AgentScope第27式·飞鸿协议
AgentScope第27式·协议边界
把 Harness 的内部事件,翻译成前端和别的 Agent 都听得懂的话。
[读者] 已跑通数字人运行页、准备接第三方前端和外部 Agent 的 Java 工程师
[痛点] 前端直接吃 Harness 内部事件,框架一升级就全断
[现在读] 第26式之后运行页要对接 AG-UI 客户端,还要被 A2A 上游调用
[读完] 能用 AG-UI / A2A 把内部状态投影成公共协议并处理 HITL 与重连
[旧方案] 自定义 SSE 直推 Harness 内部事件对象
|
v
[新需求] 前端换成 AG-UI 客户端,Agent 之间要用 A2A 通信
|
v
[冲突] 内部对象成了事实上的公共契约,改一次全断
|
v
[后果] 前端返工、跨 Agent 接不进来、HITL 与断线重连没有标准表达
我是老李,一个把数字人 Demo 从第 1 式一路改到第 26 式的 Java 工程师。到这一式,项目里已经有 Harness、Workspace、Memory、Skill、Plan、Sandbox 和 Subagent,运行页也能一边说话一边显示“正在思考”。看上去该有的都有了,但真正让它变成一个能被别人用的系统,还差一层东西。
这一层的名字叫协议。它不负责推理,不负责工具,只负责一件事:把 Harness 内部正在发生什么,翻译成外部世界能稳定依赖的东西。
问题是,翻译这件事到底该由谁做?是让前端去理解我们的内部事件,还是在网关外面再立一层公共契约?
如果继续让前端读内部对象,等框架换个字段名,我们是不是又得让前端跟着改一遍?
可如果加了协议层,是不是等于多写一堆没人看的转换代码?
01、故事
第 26 式的运行页,是我们自己写的前端。它和后端在同一个仓库里,共享同一份 DTO。后端推什么事件,前端就解什么事件,谁都没想到这有什么问题。
变化来自两个方向。第一,负责数字人页面的前端团队换人了,他们用的是 AG-UI 生态的客户端组件,不想为了我们一家写一套解析代码。第二,公司内部的编排平台想把我们登记成一个 A2A Agent,让别的 Agent 可以直接调我们。
任务是把这两个方向的接入口都留出来:对外暴露 AG-UI 事件流,同时对外提供一个可被发现的 A2A 接口,并且前端要能显示“正在查询知识”“等待确认”“子任务完成”“最终回答”这几类可公开状态。
第一次联调就卡住了。对方前端工程师问:你们这个事件里 harness.agent.think.delta 是什么意思?我盯着屏幕看了半天,发现那是我们自己内部枚举的名字,连我们自己都说不清它的边界。
那一刻我明白了:我们从来没有设计过对外契约,只是把内部实现顺手推出去了。
内部事件乱
前端各解各
协议不立界
升级即翻车
02、问题
旧做法失效的地方有三处。
第一,前端必须依赖我们的 DTO 才能解析。这意味着两个仓库永远得一起发版,任何一个字段改动都是双份工作量。
第二,HITL 没有公共表达。人类确认这一步,我们当时用的是自定义的 WAIT_CONFIRM 字符串,前端要靠写死字符串来判断“要不要弹确认框”。这不是协议,这是约定。
第三,断线重连没有语义。SSE 断开后浏览器会自动重连,但重连之后前端不知道从哪一条事件续起,结果要么丢事件,要么把同一段“正在查询”重复显示两遍。
业务上的影响很直接:接入周期长,每换一次前端就要重写一次适配。技术上的表现更本质:内部对象泄露出去,等于我们同时维护了两份契约,而其中一份自己并不承认。
本篇的完成标准定得很具体:前端只依赖协议文档就能开发;断开重连后事件不丢不重;HITL 确认之后 Agent 能回到同一次运行继续;Subagent 的起止在公共层能表达。
旧约靠私语
新人无门入
症状在页面
病根在边界
03、原理
先把两个协议一句话说清楚边界,不做百科展开。
AG-UI 是面向“Agent ↔ 前端”的交互协议,通常跑在 SSE 上,围绕一次运行的生命周期组织事件:文本消息的增量、工具调用的开始与结束、状态的快照与增量、以及人类介入的交互语义。它里面有两个关键维度:Thread 是会话,Run 是一次执行。
A2A 是面向“Agent ↔ Agent”的协议,核心对象是 Task:Agent Card 负责能力发现,Task 有明确的状态机,Message/Part 承载内容,Artifact 承载产物。跨 Agent 调用时,它关心的是“任务现在处于什么状态”,而不是“你内部哪个字段变了”。
第一个反直觉判断:协议层不是“多包一层”,而是唯一被允许泄露的状态投影面。你无法控制别人读什么,你只能控制自己投影什么。一旦接受了这个设定,你就会意识到投影白名单必须是一份显式清单,而不是顺手序列化。
第二个反直觉判断:适配层越薄越难写。字段映射是体力活,事件语义翻译是设计活。tool.call.start 到底该在数字人页面上显示成“正在查询知识”,还是“正在调用工具”,这是产品语义决策,不是 Jackson 能替你做的。
第三个反直觉判断:HITL 的难点不是“发一个确认请求”,而是“确认之后要回到同一条运行上下文”。这一点如果不提前设计,后面补起来极其痛苦,第 06 章会给出真实症状。
Harness 那边负责的是推理、Tool、状态、Memory、Skill、Subagent 这些东西,协议层不该替它做决定。协议层要做的,只是把已经发生的状态变化,翻译成公共语言。
事件非字段
语义最难译
薄层不轻写
投影须唯一
04、架构
[输入] Web 运行页 / 第三方 AG-UI 客户端 / 上游 A2A Agent
|
v
[模块] ProtocolPort(AguiAdapter / A2aAdapter)
|
v
[数据/状态] ThreadContext(threadId, runId, seq) + 投影白名单
|
v
[处理] EventProjection:HarnessEvent → PublicEvent → SSE 帧 / Task 状态
|
v
[输出] AG-UI 事件流 / A2A Task 与 Artifact
边界:ProtocolPort 只做“读内部状态、写公共事件”,不参与推理决策;Harness 只管产出事件,不关心谁来消费。
收益:前端只依赖协议,可以换;外部 Agent 通过 A2A 发现并调用我们;重连与 HITL 有了统一入口。
代价:多一次翻译成本,多一份投影规则要维护;调试时不能直接看内部字段,得先看映射表。
适用条件:有外部消费者,或者内部事件格式已经相对稳定。
不适用条件:只有自家前端、只有一个 Agent、内部事件每周都在改的时候,见第 09 章的判断。
AgentScope Java 这边,协议适配能力位于固定版本 v2.0.3 的 agentscope-extensions 模块下,官方文档在 docs/v2/zh/integration/protocol/agui.md 与 docs/v2/zh/integration/protocol/a2a.md。Harness 侧的对外网关位置是 agentscope-harness/src/main/java/io/agentscope/harness/agent/gateway/HarnessGateway.java。协议层应当站在这个网关之外,而不是改进去——具体类名与事件名请以固定 tag 源码为准,本文的适配层属于教程侧代码。
四层一条链
内态不出门
白名单投影
协议即契约
05、实战一次
分支:chapter/27-agui-a2a,从第 26 式的分支继续创建。建议提交信息为 feat(ch27): integrate AG-UI and A2A protocols。
教程版本基线是 AgentScope Java v2.0.3。这一式不新增 Agent 能力,只新增协议出口。
第一步,定义公共事件。它是协议层唯一被允许对外暴露的形态:
package io.agentscope.practice.protocol;
import java.util.Map;
/** 协议层唯一允许对外暴露的事件形态,payload 只放白名单字段。 */
public record PublicEvent(
String threadId,
String runId,
long seq,
String type,
Map<String, Object> payload) {
}
第二步,写投影器。注意这是全工程里唯一允许读 Harness 内部对象的地方:
package io.agentscope.practice.protocol;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicLong;
/** 内部事件 → 公共事件。语义翻译集中在这一处。 */
public final class EventProjection {
private final AtomicLong seq = new AtomicLong();
public List<PublicEvent> project(ThreadContext ctx, HarnessEvent e) {
long n = next(ctx);
return switch (e.kind()) {
case TEXT_DELTA -> List.of(new PublicEvent(ctx.threadId(), ctx.runId(), n,
"text.message.content", Map.of("delta", e.text())));
case TOOL_START -> List.of(new PublicEvent(ctx.threadId(), ctx.runId(), n,
"tool.call.start", Map.of("callId", e.callId(), "name", e.toolName())));
case TOOL_END -> List.of(new PublicEvent(ctx.threadId(), ctx.runId(), n,
"tool.call.end", Map.of("callId", e.callId())));
case TOOL_WAIT_CONFIRM -> List.of(new PublicEvent(ctx.threadId(), ctx.runId(), n,
"hitl.required", Map.of("callId", e.callId(), "question", e.text())));
case SUBAGENT_START -> List.of(new PublicEvent(ctx.threadId(), ctx.runId(), n,
"step.started", Map.of("name", e.subAgentName())));
case SUBAGENT_DONE -> List.of(new PublicEvent(ctx.threadId(), ctx.runId(), n,
"step.finished", Map.of("name", e.subAgentName())));
case TEXT_DONE -> List.of(new PublicEvent(ctx.threadId(), ctx.runId(), n,
"text.message.end", Map.of()));
default -> List.<PublicEvent>of();
};
}
/** 序号由服务端定义,重连时客户端按 Last-Event-ID 去重。 */
private long next(ThreadContext ctx) {
return ctx.seqBase() + seq.incrementAndGet();
}
}
第三步,把公共事件接到 SSE 上。这里最关键的是事件 id 的构造方式:
@RestController
public class AguiController {
private final AgentRuntime runtime;
private final EventProjection projection;
@PostMapping(value = "/agui/run", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter run(@RequestBody AguiRunRequest req) {
SseEmitter emitter = new SseEmitter(0L);
ThreadContext ctx = ThreadContext.open(req.threadId());
emitter.send(SseEmitter.event()
.id(ctx.threadId() + ":" + ctx.runId() + ":0")
.name("RUN_STARTED")
.data(Map.of("threadId", ctx.threadId(), "runId", ctx.runId())));
runtime.onEvent(ctx, e -> projection.project(ctx, e)
.forEach(p -> emit(emitter, p)));
runtime.resume(ctx, req.message());
return emitter;
}
private void emit(SseEmitter emitter, PublicEvent p) {
try {
emitter.send(SseEmitter.event()
.id(p.threadId() + ":" + p.runId() + ":" + p.seq())
.name(p.type())
.data(p.payload()));
} catch (IOException ex) {
// 断连不做补偿,交由客户端带 Last-Event-ID 重连后按 seq 去重
}
}
}
第四步,HITL 确认端点。这里踩过的坑,见第 06 章:
@PostMapping("/agui/confirm")
public void confirm(@RequestBody ConfirmRequest req) {
// 关键:确认不新开 run,回到同一个 threadId + runId
ThreadContext ctx = ThreadContext.attach(req.threadId(), req.runId());
runtime.resume(ctx, ToolResult.of(req.callId(), req.approved()));
}
第五步,A2A 侧的入口。Agent Card 负责被发现,Task 负责被跟踪:
@GetMapping("/.well-known/agent-card.json")
public Map<String, Object> agentCard() {
return Map.of(
"name", "digital-human-demo",
"description", "最小交互数字人 Agent",
"skills", List.of(Map.of(
"id", "chat",
"name", "数字人对话",
"description", "基于知识库的最小问答与工具调用")));
}
启动与请求验证:
mvn -q -pl digital-human-web spring-boot:run
curl -N -X POST http://localhost:8080/agui/run \
-H "Content-Type: application/json" \
-d '{"threadId":"t-1001","message":"帮我查一下产品保修政策"}'
未在当前环境实测,以下为预期结果(示例输出):
event: RUN_STARTED
id: t-1001:run-a:0
event: tool.call.start
id: t-1001:run-a:1
event: hitl.required
id: t-1001:run-a:2
验收要看三件事:断开后重连事件不重复;调用 /agui/confirm 之后 Agent 从同一次 run 继续;Subagent 的 step.started 与 step.finished 能在流里成对出现。
先跑一事件
再补重连帧
确认走同线
最小即完整
06、排查
证据链一:重连之后“正在查询知识”又出现了一次。
现象:前端断网三秒后自动重连,页面把同一条工具调用提示又渲染了一遍。
怀疑:SSE 断线重连时服务端把历史事件重放了一遍。
检查:看 SseEmitter.event().id(...) 里的 id 是否稳定,以及客户端重连时是否带了 Last-Event-ID。
证据:第一版代码里 id 用的是当前时间戳,每次重连都生成新 id,客户端根本无从判断“这条是不是刚才那条”。
根因:我们从来没把“事件序号”当成协议层状态,只是每次连接现算一个。
修复:seq 由 ThreadContext 持有并随会话持久化,id 固定为 threadId:runId:seq,客户端按 seq 去重。
证据链二:HITL 确认之后 Agent 卡住不动。
现象:前端点了“同意”,后端日志显示请求已进入,但事件流里再也没有新事件。
怀疑:内部工具调用结果没被 Harness 收到。
检查:把 /agui/confirm 请求里的 threadId 与原始 run 的 threadId 打出来对比。
证据:确认请求带的是前端新生成的一个 threadId,Harness 把它当成了全新会话,原来那次 run 仍然停在等待状态。
根因:我们把“人类确认”建模成了一次新请求,而不是同一运行上下文的恢复。
修复:确认只携带 threadId + runId + callId,回到原上下文继续。
错误尝试: 最初为了省事,我们直接把 Harness 内部事件对象序列化后塞进 AG-UI 的自定义事件里,前端从 payload 里再解内部字段。
为什么错误:这叫套壳,不叫解耦。前端仍然依赖内部类名和字段,框架一升字段名就断;而且自定义事件是给真正协议外扩展用的,把通用状态全塞进去,语义会被彻底淹没。真正要做的是投影——内部对象只在一处被读取,外部只看到白名单。
上游贡献判断:
- 是否可在最小 Demo 复现:本篇的两个问题都可在教程 Demo 内复现,但它们属于我们自己适配层的设计缺陷,不是框架缺陷。
- v2.0.3 是否存在:HITL 与重连语义需对照固定 tag 的
agui.md与agentscope-extensions源码核验,本文未据此下结论。 - main 是否仍存在:需对比 main 当前实现,避免针对已调整行为提问题。
- 是否已有 Issue/PR:需先检索官方 Issues 与 Discussions。
- 当前只能记录候选,尚无真实 Issue/PR 链接。
现象在重连
根因在游标
错译强塞包
解耦反成空
07、优化
基于第 06 章的两条根因,V2 只做三件事。
第一,把投影收成单一入口。EventProjection 是全工程唯一允许读 HarnessEvent 的类,其他任何地方都不许再 import 内部事件类型。这条约束比代码本身重要。
第二,序号归属服务端。seq 写进 ThreadContext,随会话状态走;SSE 事件的 id 恒为 threadId:runId:seq,客户端凭它去重。重连不再是“运气问题”,而是可验证的幂等行为。
第三,HITL 走同一条运行线。确认接口只接受 threadId + runId + callId,不接受“新开一个 run”。这样人机协同在协议层就是一个可恢复的中间状态,而不是一个岔路。
新行为:重连后同一段工具调用只显示一次;确认后 Agent 继续同一次运行;Subagent 起止成对出现。
验证方法:手工断开网络三秒再恢复,数页面上工具提示的出现次数;在确认框出现时刷新页面,确认恢复后运行上下文未丢失。
映射关系上保持一张表就够(最终字段名以官方文档为准):开始执行对应 RUN_STARTED 与 Task 的 working;工具调用对应 tool.call.start/end;等待确认对应 hitl.required 与 input-required;子任务完成对应 step.finished 与 Artifact;最终回答对应文本消息结束与 Task 的 completed。
投影收一处
序号定先后
白名单放行
状态不出笼
08、演进
[同一输入:前端一条消息 + 上游一次 A2A 调用]
|
+--[V1] 直推内部事件 / 前端读内部 DTO / 换前端即返工 / 重连无解
|
+--[V2] 投影为公共事件 / 前端只依赖协议 / 可重连可跨 Agent / 多一层翻译成本
|
[Trade-off]
得到:契约稳定、可换前端、可被外部 Agent 发现、HITL 可恢复
失去:内网直连的零成本、调试时能直接看内部字段的便利
边界:内部语义仍在快速变化时,投影规则会频繁调整,收益会被吃掉
正确性:V1 的正确性依赖“前后端同时发版”,V2 的正确性依赖“投影规则是否忠实”。前者是协作问题,后者是设计问题。
稳定性:V1 遇到框架升级必断,V2 只在投影层断,影响面被限制在一个类里。
复杂度:V1 表面上更简单,实际把复杂度摊给了每一个消费方;V2 把复杂度集中在一处。
成本:V2 的额外成本是每次新增内部事件都要回答“它该不该被公开”。
适用范围:只要存在外部消费者,V2 就是必须的。
遗留问题:状态目前是全量快照而非增量,长会话下流量会变大;协议版本协商还没做。
同源两出口
各付各的价
协议换自由
自由换约束
09、洞见
9.1 协议层是唯一被允许泄露的状态投影面
反直觉判断:加一层不是为了藏住内部,而是为了把“能被外部依赖的部分”从内部冻结出来。内部越乱,越需要这个稳定的面。你不需要内部整洁才能做协议,你只需要承认哪些字段从今天起不许再改。
9.2 事件语义翻译比字段映射难
反直觉判断:适配层最耗时的部分不是 DTO 转换,而是决定 tool.call.start 在数字人页面上该显示成“正在查询知识”还是“正在调用工具”。这一步没有技术正确答案,只有产品判断。把它当成技术活来做,最后会得到一个技术上正确、体验上一团糟的协议。
9.3 重连的正确性取决于序号的所有权
反直觉判断:浏览器自动重连是客户端能力,但“不丢不重”是服务端责任。只要序号是每次连接现算的,重连就永远不可能幂等。让服务端拥有序号,是重连从“祈祷”变成“工程”的唯一路径。
9.4 什么时候不应该用本章能力
反直觉判断:如果只有自家前端、只有一个 Agent、内部事件格式每周都在改,那么上 AG-UI / A2A 是净负债。你会把还没稳定的内部结构冻结成对外契约,然后被自己的契约绑死,改一次要协调三方。这时候正确顺序是先做内部事件稳定化,等有第二个消费者时再立协议。
单点做投影
多处必失控
能显非全显
何时不当用
10、系统落地
原来有什么:第 26 式的 Harness 运行时、运行页、以及我们自己写的自定义 SSE 推送。
本篇新增了什么:ProtocolPort 抽象、EventProjection 投影器、AG-UI 的 SSE 运行端点与确认端点、A2A 的 Agent Card 与 Task 状态映射。
现在能做什么:前端只依赖协议就能开发;断开重连按序号去重;HITL 确认后回到同一次运行;Subagent 的起止在公共层可见;外部 Agent 可以通过 A2A 发现并调用数字人。
还缺什么:状态目前是全量快照而非增量;协议版本协商没有做;A2A 侧的 Artifact 还没有落库;协议事件尚未纳入观测与评测指标。
下一步如何演进:把协议层的事件纳入评测回归集,任何一次投影规则改动都能跑出一致性对比;等事件量上来之后,再把全量快照换成增量。
开源参与点:发现可作为 Issue/PR 候选的问题——固定版本 v2.0.3 的协议文档对 HITL 恢复时需要携带哪些运行标识描述较为概括,若在教程 Demo 中能稳定复现文档缺口,可先到官方 Issues / Discussions 检索确认未被覆盖后,再以文档补充形式提出。当前未检索到对应链接,只能标记为候选,不构成已提交或已合并。
底座已归位
协议刚接通
下一步评测
边界再收紧
11、小结
Q1 → 内部事件经 EventProjection 单点投影为 PublicEvent,前端只依赖协议
Q2 → seq 归服务端所有并随会话持久化,HITL 回到同一 threadId + runId
Q3 → AG-UI 面向人与前端,A2A 面向 Agent 与任务,两者不能互相替代
状态 → chapter/27-agui-a2a 具备可重连、可确认、可跨 Agent 的协议出口
问一得投影
问二得游标
问三得边界
状态可交付
12、作业
12.1 理解题:AG-UI 的 Thread 和 Run 分别代表什么?为什么 HITL 必须回到同一个 Run?
参考答案:Thread 是会话维度,Run 是一次执行维度。HITL 是人类对一个正在进行中的运行做决策,本质是恢复而不是新建,所以必须携带 threadId 与 runId 回到原上下文,否则等待中的工具调用永远等不到结果。
12.2 实战题:在 chapter/27-agui-a2a 上,为 Subagent 的完成事件增加一条投影规则,并写出客户端按序号去重的伪代码。
参考答案:在 EventProjection 的 switch 中增加 SUBAGENT_DONE 分支,映射为 step.finished,payload 只放子任务名,不放内部句柄。客户端维护 lastSeq,收到事件的 seq 小于等于 lastSeq 时直接丢弃,否则渲染并更新 lastSeq。
12.3 排障题:前端重连之后状态重复显示,可能有哪些原因?请按证据顺序列出排查步骤。
参考答案:先看 SSE 事件 id 是否每次连接变化,再看客户端是否发送 Last-Event-ID,然后对比服务端 seq 的归属是否随会话持久化,最后确认重放逻辑是否在服务端被意外开启。顺序是先看协议字段,再看客户端行为,最后才怀疑框架。
12.4 架构判断题:打开固定版本 v2.0.3 的 docs/v2/zh/integration/protocol/agui.md 与 a2a.md,以及 agentscope-extensions 与 agentscope-harness/src/main/java/io/agentscope/harness/agent/gateway/HarnessGateway.java,判断哪些职责属于协议层、哪些属于 Harness 层,并说明你的划分依据。
参考答案:协议层负责事件形态、运行标识、断线语义与对外契约;Harness 层负责推理、工具、状态、记忆与子任务编排。判断依据是“谁掌握语义、谁只负责表达”。如果某段逻辑一旦换掉协议就要重写,它属于协议层;如果换掉协议它照样成立,它属于 Harness。
读码再答题
复现再下判
排障问证据
架构问边界
13、思考
协议层的价值不在转换代码本身,而在于它把“谁能依赖什么”这件事变成了显式约定。在那之前,我们的内部对象在不知情的情况下成了公共契约;在那之后,内部可以继续变,外部只认那一层投影。
这也是“会用框架”和“能参与框架演进”的分界线:会用的人把框架的事件直接推给前端,能参与演进的人会先问一句——这个字段,凭什么被别人依赖?
一念辨边界
一证定根因
一改知取舍
一役见真章

浙公网安备 33010602011771号