6.0 KiB
6.0 KiB
FC 回调知识库语音播放修复方案
问题描述
用户通过语音通话提问 → LLM 触发 search_knowledge 工具 → FC 回调执行知识库查询 → 结果无法通过 S2S 语音播放给用户。
根因分析
根因 1:ExternalTextToSpeech 200 字符限制
官方文档明确规定(自定义语音播放):
Message: 要播报的文本内容,长度不超过 200 个字符。
知识库返回内容通常 500~2000 字符,远超此限制,导致 API 静默拒绝或截断。
根因 2:Command:"function" 在混合模式下不可靠
在 S2S+LLM 混合模式(OutputMode=1)下:
Command:"function"将工具结果返回给 LLM 处理- 但 LLM 润色后的回复可能不通过 S2S 管道播放
- LLM 认为工具未返回结果,触发无限重试(日志中同一问题出现 3 个不同
call_id)
根因 3:TaskId 不匹配
- FC 回调中的
TaskID是 RTC 内部 UUID(如f6c8cddf-...) StartVoiceChat的TaskId是自定义格式(如task_xxx_timestamp)- 导致
UpdateVoiceChat命令发送到错误的 Task
根因 4:延迟瓶颈
原始串行流程耗时约 18 秒:
1s chunk收集 → 0.5s interrupt → 0.5s 安抚语 → 15s KB查询 → 1s TTS = ~18s
修复方案
修复 1:分段 TTS 播放(解决 200 字符限制)
文件: server/routes/voice.js
将 KB 结果按自然断句拆分为 ≤200 字符的段落,逐段通过 ExternalTextToSpeech 播放:
// 分段函数:在句号、问号、感叹号等自然断点处拆分
const MAX_TTS_LEN = 200; // 官方限制
const MAX_TOTAL_LEN = 800; // 总内容上限,避免播放过久
const splitForTTS = (text, maxLen) => {
const segments = [];
let remaining = text;
while (remaining.length > 0) {
if (remaining.length <= maxLen) { segments.push(remaining); break; }
let cutAt = -1;
const breakChars = ['。', '!', '?', ';', '\n', ',', '、'];
for (const ch of breakChars) {
const idx = remaining.lastIndexOf(ch, maxLen - 1);
if (idx > cutAt) cutAt = idx;
}
if (cutAt <= 0) cutAt = maxLen;
else cutAt += 1;
segments.push(remaining.substring(0, cutAt));
remaining = remaining.substring(cutAt).trim();
}
return segments.filter(s => s.length > 0);
};
播放策略:
- 第一段
InterruptMode: 1(高优先级,打断安抚语) - 后续段
InterruptMode: 2(中优先级,排队播放)
修复 2:Command:function 异步通知 LLM(解决无限重试)
在 ExternalTextToSpeech 播放后,异步发送 Command:"function" 让 LLM 知道工具已返回结果,停止重试:
if (b.id) {
volcengine.updateVoiceChat({
Command: 'function',
Message: JSON.stringify({ ToolCallID: b.id, Content: contentText.substring(0, 2000) }),
}).catch(e => console.warn('Command:function failed (non-critical):', e.message));
}
修复 3:30 秒 Cooldown 防重试(解决 LLM 无限重试)
文件: server/routes/voice.js
在工具结果发送后,设置 30 秒 cooldown,期间忽略相同 TaskId 的重复调用:
const cooldownMs = existing.resultSentAt ? 30000 : 15000;
const elapsed = existing.resultSentAt
? (Date.now() - existing.resultSentAt)
: (Date.now() - existing.createdAt);
if (elapsed < cooldownMs) {
console.log(`Cooldown active, ignoring retry`);
return;
}
修复 4:TaskId 解析优先级(解决 TaskId 不匹配)
使用三级回退策略解析正确的 TaskId:
const s2sTaskId = roomToTaskId.get(b.RoomID) || b.S2STaskID || effectiveTaskId;
- 优先:
roomToTaskId(从 StartVoiceChat 响应中捕获的服务端 TaskId) - 其次:回调中的
S2STaskID - 兜底:回调中的原始
TaskID
修复 5:延迟优化(减少 ~1.5 秒等待)
文件: server/routes/voice.js
| 优化项 | 修改前 | 修改后 | 节省 |
|---|---|---|---|
| chunk 收集超时 | 1000ms | 500ms | 500ms |
| interrupt 命令 | 单独发送 ~500ms | 移除(InterruptMode:1 已含打断) | 500ms |
| 安抚语 vs KB 查询 | 串行等待 | Promise.all 并行 |
~500ms |
优化后流程:
0.5s chunk收集 → [安抚语 + KB查询 并行] → 1s TTS分段 = ~16.5s
// 并行执行:安抚语 + KB 查询同时进行
const waitingPromptPromise = volcengine.updateVoiceChat({
Command: 'ExternalTextToSpeech',
Message: '正在查询知识库,请稍候。',
InterruptMode: 1,
}).catch(err => console.warn('Waiting prompt failed:', err.message));
const kbQueryPromise = ToolExecutor.execute(toolName, parsedArgs);
const [, kbResult] = await Promise.all([waitingPromptPromise, kbQueryPromise]);
修复 6:Ark KB 超时缩短
文件: server/services/toolExecutor.js
timeout: 15000, // 从 30s 减到 15s,减少等待
修改文件清单
| 文件 | 修改内容 |
|---|---|
server/routes/voice.js |
FC 回调处理:分段 TTS、并行执行、cooldown、TaskId 解析 |
server/services/toolExecutor.js |
Ark KB 超时从 30s 减到 15s |
server/.env |
FC_SERVER_URL 更新为部署域名 |
关键参考文档
- 自定义语音播放(ExternalTextToSpeech) — 200 字符限制
- Function Calling — FC 回调机制
- 接入知识库 RAG — 官方推荐 Coze/MCP 方式
- UpdateVoiceChat API — Command 参数说明
后续优化方向
如果当前方案的 15s KB 查询延迟仍然不可接受,可考虑:
- 迁移到 Coze Bot 内置知识库:
LLMConfig.Mode="CozeBot",知识库查询由 Coze 内部完成,减少网络往返 - 接入 MCP Server:通过 Viking 知识库 MCP 直接集成
- 本地知识库缓存:对高频问题预加载结果,命中缓存时延迟 <1s