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:
@@ -17,10 +17,18 @@ import { MetonaStreamEventType } from '../../types';
|
||||
* L-4 修复: 提取 flushToolCallBuffer 辅助函数,消除 [DONE] 分支和 finish_reason='tool_calls' 分支的重复代码
|
||||
*
|
||||
* 遍历工具调用缓冲区,对每个缓冲的工具调用:
|
||||
* 1. JSON.parse argsBuffer(失败则跳过)
|
||||
* 1. JSON.parse argsBuffer
|
||||
* 2. yield 一个 TOOL_CALL_COMPLETE 事件
|
||||
* 3. 清空缓冲区
|
||||
*
|
||||
* v0.6.3 会话停止修复: argsBuffer 解析失败(流截断致 JSON 半截 — 典型场景:
|
||||
* 模型写大文件时输出 token 达上限 finish_reason=length)时,不再静默丢弃该
|
||||
* 工具调用。丢弃会让引擎看到"零工具调用 + 零文本"→ 误判为模型已完成 →
|
||||
* COMPLETED + 空回复 → 会话无声停止(main.log 20:31/20:32 两次实锤)。
|
||||
* 现转为 yield 一个携带截断错误说明的 tool call:工具执行将因参数缺失失败,
|
||||
* 错误结果回传模型 → 模型感知截断后重试/分块写入(ReAct 自愈路径)。
|
||||
* 无限循环由引擎死循环检测器兜底。
|
||||
*
|
||||
* @param toolCallsBuffer - 工具调用缓冲区(index → { name, argsBuffer })
|
||||
* @param requestId - 请求 ID
|
||||
* @param sessionId - 会话 ID
|
||||
@@ -36,7 +44,56 @@ function* flushToolCallBuffer(
|
||||
seqRef: { seq: number },
|
||||
): Generator<MetonaStreamEvent> {
|
||||
for (const [, buf] of toolCallsBuffer) {
|
||||
try {
|
||||
if (buf.argsBuffer) {
|
||||
try {
|
||||
yield {
|
||||
type: MetonaStreamEventType.TOOL_CALL_COMPLETE,
|
||||
requestId,
|
||||
sessionId,
|
||||
iteration,
|
||||
seq: seqRef.seq++,
|
||||
timestamp: Date.now(),
|
||||
toolCall: {
|
||||
id: `tc_${nanoid(8)}`,
|
||||
name: buf.name,
|
||||
args: JSON.parse(buf.argsBuffer),
|
||||
iteration,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
// v0.6.3: 截断的工具调用转显式错误参数(不丢弃)— 工具执行失败后
|
||||
// 错误结果回传模型,触发重试/分块写入,替代"静默丢弃→空回复终止会话"
|
||||
const rawTail = buf.argsBuffer.slice(-120);
|
||||
log.warn(
|
||||
`[SSE] Tool call args truncated (unparseable JSON, ${(err as Error).message}). ` +
|
||||
`Forwarding as error to model for self-healing. Tail: ...${rawTail}`,
|
||||
);
|
||||
yield {
|
||||
type: MetonaStreamEventType.TOOL_CALL_COMPLETE,
|
||||
requestId,
|
||||
sessionId,
|
||||
iteration,
|
||||
seq: seqRef.seq++,
|
||||
timestamp: Date.now(),
|
||||
toolCall: {
|
||||
id: `tc_${nanoid(8)}`,
|
||||
name: buf.name,
|
||||
args: {
|
||||
_truncatedArguments: true,
|
||||
_truncatedReason:
|
||||
'The streamed arguments JSON was truncated before completion ' +
|
||||
'(likely max_tokens output limit reached while generating this tool call). ' +
|
||||
'The original arguments are lost. Please retry with smaller output ' +
|
||||
'(e.g. write the file in smaller chunks) — do NOT reuse the previous oversized arguments.',
|
||||
},
|
||||
iteration,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// 空 argsBuffer:模型发了空 arguments(合法 — 无参工具)
|
||||
yield {
|
||||
type: MetonaStreamEventType.TOOL_CALL_COMPLETE,
|
||||
requestId,
|
||||
@@ -47,19 +104,11 @@ function* flushToolCallBuffer(
|
||||
toolCall: {
|
||||
id: `tc_${nanoid(8)}`,
|
||||
name: buf.name,
|
||||
args: buf.argsBuffer ? JSON.parse(buf.argsBuffer) : {},
|
||||
args: {},
|
||||
iteration,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
// #25 修复: 不再静默丢弃 JSON 解析失败的工具调用
|
||||
// 审查修复: 不再 yield ERROR 事件,因为 Engine 收到 ERROR 会 throw 中断整个请求,
|
||||
// 导致一个好的工具调用 JSON 解析失败就丢弃所有工具调用。
|
||||
// 改为 log.warn 记录后 continue 跳过这条坏的工具调用,继续处理 buffer 中剩余的。
|
||||
const rawPreview = buf.argsBuffer?.slice(0, 200) ?? '';
|
||||
log.warn(`[SSE] Tool call JSON parse failed: ${(err as Error).message}`, rawPreview);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
toolCallsBuffer.clear();
|
||||
@@ -84,6 +133,8 @@ export async function* parseSSEStream(
|
||||
const decoder = new TextDecoder();
|
||||
const seqRef = { seq: 0 };
|
||||
let buffer = '';
|
||||
// v0.6.3: 是否收到过 [DONE](流断开兜底用)
|
||||
let sawDone = false;
|
||||
|
||||
// 工具调用缓冲区:index → { name, argsBuffer }
|
||||
const toolCallsBuffer = new Map<number, { name: string; argsBuffer: string }>();
|
||||
@@ -103,6 +154,7 @@ export async function* parseSSEStream(
|
||||
|
||||
// 流结束
|
||||
if (data === '[DONE]') {
|
||||
sawDone = true;
|
||||
// L-4 修复: 使用 flushToolCallBuffer 替代重复的遍历代码
|
||||
yield* flushToolCallBuffer(toolCallsBuffer, requestId, sessionId, iteration, seqRef);
|
||||
|
||||
@@ -183,8 +235,9 @@ export async function* parseSSEStream(
|
||||
reasoningTokens: chunk.usage.completion_tokens_details?.reasoning_tokens,
|
||||
// DeepSeek: prompt_cache_hit_tokens / prompt_cache_miss_tokens
|
||||
// MiMo: prompt_tokens_details.cached_tokens
|
||||
cacheHitTokens: chunk.usage.prompt_cache_hit_tokens
|
||||
?? chunk.usage.prompt_tokens_details?.cached_tokens,
|
||||
cacheHitTokens:
|
||||
chunk.usage.prompt_cache_hit_tokens ??
|
||||
chunk.usage.prompt_tokens_details?.cached_tokens,
|
||||
cacheMissTokens: chunk.usage.prompt_cache_miss_tokens,
|
||||
};
|
||||
|
||||
@@ -205,23 +258,57 @@ export async function* parseSSEStream(
|
||||
// L-4 修复: 使用 flushToolCallBuffer 替代重复的遍历代码
|
||||
yield* flushToolCallBuffer(toolCallsBuffer, requestId, sessionId, iteration, seqRef);
|
||||
}
|
||||
// v0.6.3 归因: 输出 token 上限截断(长工具参数/长文本的常见根因)显式落日志
|
||||
if (finishReason === 'length') {
|
||||
log.warn(
|
||||
`[SSE] finish_reason=length — output truncated by max_tokens limit ` +
|
||||
`(accumulated argsBuffer: ${[...toolCallsBuffer.values()].reduce((n, b) => n + b.argsBuffer.length, 0)} chars, ` +
|
||||
`model may retry with smaller output)`,
|
||||
);
|
||||
}
|
||||
} catch (parseErr) {
|
||||
// P2-8 修复: 不再静默跳过,记录 warning 便于排查 SSE 数据损坏
|
||||
log.warn(`[SSE] Failed to parse stream line: ${(parseErr as Error).message}`, line.slice(0, 200));
|
||||
log.warn(
|
||||
`[SSE] Failed to parse stream line: ${(parseErr as Error).message}`,
|
||||
line.slice(0, 200),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// v0.6.3 流断开兜底: read() done 但从未收到 [DONE](连接中断/服务端异常收尾)。
|
||||
// 原实现直接结束 generator —— 工具缓冲不 flush、DONE 事件缺失(引擎侧等待
|
||||
// 流收尾的路径行为未定义,且缓冲的工具调用整体丢失)。补 flush + DONE,
|
||||
// 截断的参数由 flushToolCallBuffer 转为错误结果回传模型自愈。
|
||||
if (!sawDone) {
|
||||
log.warn(
|
||||
'[SSE] Stream ended without [DONE] marker — flushing buffers (connection likely dropped)',
|
||||
);
|
||||
yield* flushToolCallBuffer(toolCallsBuffer, requestId, sessionId, iteration, seqRef);
|
||||
yield {
|
||||
type: MetonaStreamEventType.DONE,
|
||||
requestId,
|
||||
sessionId,
|
||||
iteration,
|
||||
seq: seqRef.seq++,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 OpenAI 兼容的非流式 JSON 响应 → MetonaResponse
|
||||
*/
|
||||
export function parseOpenAICompatibleResponse(
|
||||
data: Record<string, unknown>,
|
||||
): {
|
||||
export function parseOpenAICompatibleResponse(data: Record<string, unknown>): {
|
||||
content: string;
|
||||
reasoningContent?: string;
|
||||
toolCalls?: Array<{ id: string; name: string; args: Record<string, unknown>; iteration: number; timestamp: number }>;
|
||||
toolCalls?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
iteration: number;
|
||||
timestamp: number;
|
||||
}>;
|
||||
finishReason: string;
|
||||
usage: MetonaTokenUsage;
|
||||
} {
|
||||
@@ -259,10 +346,14 @@ export function parseOpenAICompatibleResponse(
|
||||
inputTokens: (usage?.prompt_tokens as number) ?? 0,
|
||||
outputTokens: (usage?.completion_tokens as number) ?? 0,
|
||||
totalTokens: (usage?.total_tokens as number) ?? 0,
|
||||
reasoningTokens: (usage?.completion_tokens_details as Record<string, unknown>)?.reasoning_tokens as number | undefined,
|
||||
reasoningTokens: (usage?.completion_tokens_details as Record<string, unknown>)
|
||||
?.reasoning_tokens as number | undefined,
|
||||
// DeepSeek: prompt_cache_hit_tokens / MiMo: prompt_tokens_details.cached_tokens
|
||||
cacheHitTokens: (usage?.prompt_cache_hit_tokens as number | undefined)
|
||||
?? (usage?.prompt_tokens_details as Record<string, unknown> | undefined)?.cached_tokens as number | undefined,
|
||||
cacheHitTokens:
|
||||
(usage?.prompt_cache_hit_tokens as number | undefined) ??
|
||||
((usage?.prompt_tokens_details as Record<string, unknown> | undefined)?.cached_tokens as
|
||||
| number
|
||||
| undefined),
|
||||
cacheMissTokens: usage?.prompt_cache_miss_tokens as number | undefined,
|
||||
},
|
||||
};
|
||||
@@ -270,12 +361,18 @@ export function parseOpenAICompatibleResponse(
|
||||
|
||||
function mapOpenAIFinishReason(reason: string): string {
|
||||
switch (reason) {
|
||||
case 'stop': return 'stop';
|
||||
case 'length': return 'length';
|
||||
case 'tool_calls': return 'tool_calls';
|
||||
case 'content_filter': return 'content_filter';
|
||||
case 'stop':
|
||||
return 'stop';
|
||||
case 'length':
|
||||
return 'length';
|
||||
case 'tool_calls':
|
||||
return 'tool_calls';
|
||||
case 'content_filter':
|
||||
return 'content_filter';
|
||||
// MiMo 特有:检测到复读截断
|
||||
case 'repetition_truncation': return 'stop';
|
||||
default: return 'stop';
|
||||
case 'repetition_truncation':
|
||||
return 'stop';
|
||||
default:
|
||||
return 'stop';
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user