流式中断标记退化:SSE 丢弃 QueryChunk.type 信封,导致中断只能靠 items/message 弱推断
一句话
REST 流式(SSE)场景下,QueryChunk.type("interrupt")这个本可一锤定音的中断判别器,在序列化时被 QuerySseSupport.payload() 丢弃,客户端被迫退回"有 message/items 就当中断"的启发式推断;而仓内已有测试断言业务输出也可能带 items,因此该启发式已知不可靠。非流式 JSON 不受影响(result._interrupt 是可靠标记)。
背景与影响范围
- 受影响接口:REST
POST /v1/query、POST /v1/query/reactive(及兼容路径 /query),仅 stream=true(SSE)。
- 不受影响:
stream=false(非流式 JSON,靠 result._interrupt 区分,可靠);A2A JSON-RPC(走独立封装,不在本缺陷范围)。
- 严重度:高。前端无法在流式下可靠区分"中断(需用户输入)"与"普通中间/最终文本块",会误判中断为应答(提前关闭流)或误判应答为中断(卡住等输入)。
问题详述
1. QueryChunk 设计了显式 type 判别器,但 SSE 没把它发出去
QueryChunk 在内存里有明确的 type 字段区分中断与普通块:
// service/agent-service-spec/.../dto/QueryChunk.java:16-23
public static final String TYPE_INTERRUPT = "interrupt";
public static final String TYPE_CHUNK = "chunk";
public static final String TYPE_REMOTE_AGENT_OUTPUT = "remote_agent_output";
public static final String TYPE_ERROR = "error";
private String type = TYPE_CHUNK;
private Object data;
但 SSE 序列化层只取 data、丢弃 type 信封:
// service/agent-service-app/.../controller/query/QuerySseSupport.java:30-37
public static Object payload(QueryChunk chunk) {
if (chunk.getData() != null) {
return chunk.getData(); // ← 只返回 data,type 信封被丢
}
Map<String, Object> fallback = new LinkedHashMap<>();
fallback.put("type", chunk.getType()); // ← 仅 data==null 时回退,中断块 data 永远非空
return fallback;
}
MVC 与 WebFlux 控制器又都只 .data(...)、不设 SSE 事件名:
// QueryMvcController.java:142
emitter.send(SseEmitter.event().data(QuerySseSupport.toSseData(chunk, objectMapper)));
// QueryWebFluxController.java:125
sink.next(ServerSentEvent.builder(QuerySseSupport.toSseData(chunk, objectMapper)).build());
结果:SSE 线格式 = 纯 data 的 JSON,既无 type 信封,也无 event: 名称。
2. 客户端实际看到的中断数据形状(无统一 type 标记)
| 中断来源 |
SSE 上发出的 JSON |
内部有 type 吗 |
构造点 |
| 单个本地中断(ask_user) |
{type:"__interaction__", index, payload, message, toolCallId, ...} |
✅ __interaction__ |
JiuwenCoreAgentHandler.toInterruptData(JiuwenCoreAgentHandler.java:635-646) |
| 批量本地中断 |
{message:"interaction batch", items:[...]} |
❌ 无 |
JiuwenCoreAgentHandler.normalizeInterrupts(JiuwenCoreAgentHandler.java:416-428) |
| 远端 a2a_delegate 批量中断 |
{message:..., items:[{toolCallId, toolName, message}]} |
❌ 无 |
RemoteInvocationBatchMapper.publicInterrupt(RemoteInvocationBatchMapper.java:243-260) |
| 普通中间/最终块 |
{type:"answer"/"chunk"/..., payload} 或纯字符串 |
✅ 其他值 |
JiuwenCoreAgentHandler.streamQuery 透传 |
远端批量中断的发出点明确带 TYPE_INTERRUPT 信封,但被 payload() 丢掉:
// A2AEnabledServeOrchestrator.java:454
observer.onNext(new QueryChunk(QueryChunk.TYPE_INTERRUPT, resolution.interrupt()));
3. 弱推断已被本仓测试否决
由于批量/远端中断在 SSE data 内部无 type,客户端只能退回"有 items 就当中断""有 message 且无 type 就当中断"这类启发式。但仓内已有测试断言业务输出也可能带 items:
矛盾点:服务端自己已否决"items 推断",但 SSE 线格式却把客户端逼回这个被否决的启发式,因为唯一可靠的判别器 QueryChunk.type="interrupt" 被序列化层丢了。
4. 对比:非流式 JSON 是可靠的
非流式响应靠 result Map 中的 _interrupt key 做判别器,所有中断路径都置、普通应答从不置;服务端自己也用它判别:
// A2AEnabledServeOrchestrator.java:429-435
if (response.getResult() instanceof Map<?, ?> m && m.get("_interrupt") instanceof Map<?, ?> interrupt) {
return (Map<String, Object>) interrupt;
}
普通应答只写 role/content,不写 _interrupt(JiuwenCoreAgentHandler.java:366-371)。故 result._interrupt != null 100% 可靠。本缺陷仅限流式 SSE。
复现/证据清单
影响
- 前端误判:流式下无法可靠区分中断与普通块。批量本地中断、远端 a2a_delegate 中断在 SSE data 内部无
type,与带 items 的业务输出形状重叠。
- 行为不一致:同一中断语义,非流式有
result._interrupt 强标记、流式却退化为弱推断,客户端需写两套解析逻辑。
- 被本仓测试自相矛盾:服务端已认定"items 推断不可靠",SSE 线格式却使客户端别无选择。
修复方向
最小修复集中在 QuerySseSupport,让 SSE 报文携带 QueryChunk.type 信封。二选一:
- 信封包裹(推荐):把 SSE data 从纯 payload 改为
{type, data},例如中断块发 {"type":"interrupt","data":{message,items}},普通块发 {"type":"chunk","data":{...}}。客户端只需判顶层 type。
- SSE 事件名:给
SseEmitter.event().name(chunk.getType()) / ServerSentEvent.builder(...).event(chunk.getType()) 设 event: 字段,客户端按事件名分发。
注意:方案 1 会改变 SSE data 形状,是线格式变更,需评估对既有客户端的兼容性;方案 2 不动 data、只加事件名,兼容性更好。两种方案都应同时给批量/远端中断在 data 内部补一个稳定的 type 标记(与非流式 _interrupt 语义对齐),消除"单中断有 __interaction__、批量/远端无"的内部不一致。
关联
- 姊妹问题(并发
conversation_id 拼装 + _remote_invocation 单层):见主 issue(本仓 #71)。
- 背景文档:
documents/zh/deepagent-a2a-delegate-dual-channel-leak-defect.cn.md。
流式中断标记退化:SSE 丢弃
QueryChunk.type信封,导致中断只能靠 items/message 弱推断一句话
REST 流式(SSE)场景下,
QueryChunk.type("interrupt")这个本可一锤定音的中断判别器,在序列化时被QuerySseSupport.payload()丢弃,客户端被迫退回"有message/items就当中断"的启发式推断;而仓内已有测试断言业务输出也可能带items,因此该启发式已知不可靠。非流式 JSON 不受影响(result._interrupt是可靠标记)。背景与影响范围
POST /v1/query、POST /v1/query/reactive(及兼容路径/query),仅stream=true(SSE)。stream=false(非流式 JSON,靠result._interrupt区分,可靠);A2A JSON-RPC(走独立封装,不在本缺陷范围)。问题详述
1.
QueryChunk设计了显式 type 判别器,但 SSE 没把它发出去QueryChunk在内存里有明确的type字段区分中断与普通块:但 SSE 序列化层只取
data、丢弃type信封:MVC 与 WebFlux 控制器又都只
.data(...)、不设 SSE 事件名:结果:SSE 线格式 = 纯
data的 JSON,既无type信封,也无event:名称。2. 客户端实际看到的中断数据形状(无统一 type 标记)
type吗{type:"__interaction__", index, payload, message, toolCallId, ...}__interaction__JiuwenCoreAgentHandler.toInterruptData(JiuwenCoreAgentHandler.java:635-646){message:"interaction batch", items:[...]}JiuwenCoreAgentHandler.normalizeInterrupts(JiuwenCoreAgentHandler.java:416-428){message:..., items:[{toolCallId, toolName, message}]}RemoteInvocationBatchMapper.publicInterrupt(RemoteInvocationBatchMapper.java:243-260){type:"answer"/"chunk"/..., payload}或纯字符串JiuwenCoreAgentHandler.streamQuery透传远端批量中断的发出点明确带
TYPE_INTERRUPT信封,但被payload()丢掉:3. 弱推断已被本仓测试否决
由于批量/远端中断在 SSE data 内部无
type,客户端只能退回"有items就当中断""有message且无type就当中断"这类启发式。但仓内已有测试断言业务输出也可能带items:JiuwenCoreAgentHandlerTest.queryDoesNotTreatBusinessBatchShapeAsInterrupt(JiuwenCoreAgentHandlerTest.java:265-272)——证明items不是中断的可靠标记。矛盾点:服务端自己已否决"items 推断",但 SSE 线格式却把客户端逼回这个被否决的启发式,因为唯一可靠的判别器
QueryChunk.type="interrupt"被序列化层丢了。4. 对比:非流式 JSON 是可靠的
非流式响应靠
resultMap 中的_interruptkey 做判别器,所有中断路径都置、普通应答从不置;服务端自己也用它判别:普通应答只写
role/content,不写_interrupt(JiuwenCoreAgentHandler.java:366-371)。故result._interrupt != null100% 可靠。本缺陷仅限流式 SSE。复现/证据清单
payload丢弃 type);QuerySseSupport.java:47-49(toJson调payload)。TYPE_INTERRUPT等常量)。影响
type,与带items的业务输出形状重叠。result._interrupt强标记、流式却退化为弱推断,客户端需写两套解析逻辑。修复方向
最小修复集中在
QuerySseSupport,让 SSE 报文携带QueryChunk.type信封。二选一:{type, data},例如中断块发{"type":"interrupt","data":{message,items}},普通块发{"type":"chunk","data":{...}}。客户端只需判顶层type。SseEmitter.event().name(chunk.getType())/ServerSentEvent.builder(...).event(chunk.getType())设event:字段,客户端按事件名分发。注意:方案 1 会改变 SSE data 形状,是线格式变更,需评估对既有客户端的兼容性;方案 2 不动 data、只加事件名,兼容性更好。两种方案都应同时给批量/远端中断在 data 内部补一个稳定的
type标记(与非流式_interrupt语义对齐),消除"单中断有__interaction__、批量/远端无"的内部不一致。关联
conversation_id拼装 +_remote_invocation单层):见主 issue(本仓 #71)。documents/zh/deepagent-a2a-delegate-dual-channel-leak-defect.cn.md。