fix: v0.6.3 修复截断工具调用被静默丢弃导致空回复终止会话 — SSE 流截断转模型自愈
【根因(main.log 实证)】 2026-08-22 20:31:22 / 20:32:31 两次 + 20:37:01 最终终止,完整因果链: 模型写大文件(22KB HTML,write_file)时输出 token 达上限 (finish_reason=length),流正常收尾但 tool_call 的 arguments JSON 半截 ("Unterminated string in JSON at position 21890/22686")。 缺陷链: 1. SSE 解析器对 parse 失败的 tool call 静默丢弃(log.warn 后 continue — #25 时代为防单个坏 JSON 丢弃全部而引入) 2. 该轮模型输出全部是这一个 tool call → 丢弃后引擎看到"零工具调用 + 零文本"→ 误判为模型已完成 → COMPLETED + 空回复(OutputValidator 报 "Output is empty" 仅 warn 不阻断) 3. 用户感知:AI 干了 16 轮 10.5 分钟后会话无声停止、没有最终回复 20:31/20:32 两次截断后模型自行重试(日志可见继续 EXECUTING), 但 20:37 最后一轮再次截断且无重试机会 → 空回复终止。 【修复(sse-stream.ts — DeepSeek/Agnes/MiMo 三家共享)】 - flushToolCallBuffer: parse 失败的 tool call 不再丢弃 — 转为携带 _truncatedArguments + _truncatedReason(明确告知模型"参数因输出长度 限制被截断,请分块重试、勿复用原参数")的 TOOL_CALL_COMPLETE。 工具执行将因参数缺失失败,错误结果回传模型 → 模型感知截断后分块 写入(ReAct 自愈路径)。无限循环由引擎死循环检测器兜底 - 流断开兜底: read() done 但从未收到 [DONE](连接中断)时补 flush + DONE — 原实现缓冲整体丢失且引擎收尾路径行为未定义 - finish_reason=length 显式 warn 日志(含缓冲字节数)— 归因能力 - 空 argsBuffer 的 tool call(无参工具)显式产出 args={}(原实现走 JSON.parse('') 会进 catch,行为巧合正确但语义混乱) 【测试】 +5 用例(sse-truncation.test.ts): 截断转错误说明 / 无 [DONE] 兜底 / 完整 JSON 回归 / 空 args 回归 / finish_reason=tool_calls 提前 flush 更新 1 旧用例: "损坏 JSON 跳过" → "损坏 JSON 转截断错误 tool call" (行为变更的契约级断言) 【验证】 lint 0/0;typecheck 双工程 0 错误;test:electron 264/264(+5); electron-vite build 成功
This commit is contained in:
@@ -99,7 +99,7 @@ describe('parseSSEStream — 工具调用增量拼接', () => {
|
||||
expect(names).toEqual(['tool_a', 'tool_b']);
|
||||
});
|
||||
|
||||
it('损坏的 args JSON 跳过该工具调用且不中断流', async () => {
|
||||
it('损坏的 args JSON 转为截断错误 tool call 回传模型(不丢弃、不中断流)', async () => {
|
||||
const events = await collect(
|
||||
makeStream([
|
||||
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"bad_tool","arguments":"{invalid json"}}]}}]}\n\n',
|
||||
@@ -107,10 +107,14 @@ describe('parseSSEStream — 工具调用增量拼接', () => {
|
||||
'data: [DONE]\n\n',
|
||||
]),
|
||||
);
|
||||
// 坏 JSON 的工具调用被跳过(不产出 TOOL_CALL_COMPLETE)
|
||||
expect(events.filter((e) => e.type === MetonaStreamEventType.TOOL_CALL_COMPLETE)).toHaveLength(
|
||||
0,
|
||||
);
|
||||
// v0.6.3 契约: 坏 JSON 的工具调用不再静默丢弃(丢弃会让引擎误判 COMPLETED
|
||||
// 空回复终止会话)— 转为携带 _truncatedArguments 错误说明的 tool call,
|
||||
// 工具执行失败后错误结果回传模型触发自愈
|
||||
const completes = events.filter((e) => e.type === MetonaStreamEventType.TOOL_CALL_COMPLETE);
|
||||
expect(completes).toHaveLength(1);
|
||||
const tc = completes[0].toolCall as { name: string; args: Record<string, unknown> };
|
||||
expect(tc.name).toBe('bad_tool');
|
||||
expect(tc.args._truncatedArguments).toBe(true);
|
||||
// 流继续处理后续事件
|
||||
expect(events.some((e) => e.type === MetonaStreamEventType.TEXT_DELTA)).toBe(true);
|
||||
expect(events[events.length - 1].type).toBe(MetonaStreamEventType.DONE);
|
||||
|
||||
Reference in New Issue
Block a user