问题背景
本文记录在使用
阿里云百炼平台 Coding Plan(GLM-5 模型) 时遇到的一个流式输出协议异常问题。请求客户端为 Neovim 插件
chat.nvim,通过
curl 命令异步调用阿里云的流式聊天补全接口。
在正常开发过程中,chat.nvim 需要调用大模型完成代码生成、问题解答等任务,并支持 Tool Call 功能以扩展模型能力。然而在实际使用中,发现当模型返回 Tool Call 响应时,SSE(Server-Sent Events)流中会夹杂未经封装的原始文本数据,导致客户端解析失败。
调用架构与实现
客户端请求实现
chat.nvim 底层使用 Neovim 的 job 系统异步执行 curl 命令,通过标准输入输出与服务端通信。核心请求函数如下:
function M.request(opt)
local cmd = {
'curl',
'-s',
'https://coding.dashscope.aliyuncs.com/v1/chat/completions',
'-H',
'Content-Type: application/json',
'-H',
'Authorization: Bearer ' .. config.config.api_key.aliyuncs_pc,
'-X',
'POST',
'-d',
'@-',
}
local body = vim.json.encode({
model = sessions.get_session_model(opt.session),
messages = opt.messages,
enable_thinking = true,
stream = true,
stream_options = { include_usage = true },
tools = require('chat.tools').available_tools(),
})
local jobid = job.start(cmd, {
on_stdout = opt.on_stdout,
on_stderr = opt.on_stderr,
on_exit = opt.on_exit,
})
job.send(jobid, body)
job.send(jobid, nil)
sessions.set_session_jobid(opt.session, jobid)
return jobid
end
关键请求参数
| 参数 |
配置值 |
说明 |
| 服务端点 |
https://coding.dashscope.aliyuncs.com/v1/chat/completions |
阿里云百炼平台 API |
| 模型 |
glm-5 |
GLM-5 模型(Coding Plan) |
| 流式输出 |
stream = true |
启用 SSE 流式响应 |
| Tool Call |
tools = available_tools() |
注册可用工具函数 |
| 思考模式 |
enable_thinking = true |
启用模型思考过程 |
| 使用量统计 |
stream_options.include_usage = true |
返回 token 使用统计 |
请求体通过
curl -d @- 从标准输入读取,job 系统通过
job.send() 将 JSON 编码的请求体发送给 curl 进程。响应数据通过
on_stdout 回调逐行接收,客户端需要实时解析 SSE 格式的数据流。
异常现象详述
正常的 SSE 流格式
符合 OpenAI 规范的 SSE 流式响应应当严格遵循以下格式,每一行都以
data: 前缀开头,后面跟随完整的 JSON 对象或
[DONE] 标记:
data: {"id":"chat-123","choices":[{"delta":{"content":"Hello"},"index":0}]}
data: {"id":"chat-123","choices":[{"delta":{"content":" world"},"index":0}]}
data: {"id":"chat-123","choices":[{"delta":{"content":"!"},"index":0}]}
data: {"id":"chat-123","choices":[{"finish_reason":"stop","index":0}]}
data: [DONE]
客户端解析器通过检测
data: 前缀识别有效数据行,使用
vim.json.decode() 解析 JSON 内容,遇到
[DONE] 标记时结束流式处理。
实际收到的异常数据
在实际调用 GLM-5 模型时,收到的响应流中出现了不符合 SSE 规范的裸文本数据:
data: {"id":"chat-456","choices":[{"delta":{"content":"请查看"},"index":0}]}
data: {"id":"chat-456","choices":[{"delta":{"tool_calls":[{"id":"call_1","function":{"name":"read_file","arguments":""},"index":0}]}]}
ts from you under
this License for any purpose whatsoever, with or without
data: {"id":"chat-456","choices":[{"delta":{"tool_calls":[{"function":{"arguments":"\"--filepath\""},"index":0}]}]}
data: {"id":"chat-456","choices":[{"finish_reason":"tool_calls","index":0}]}
data: [DONE]
可以看到在第 2 行和第 3 行之间,插入了两行没有
data: 前缀的纯文本内容。这些文本实际上是 Tool Call 的
arguments 参数值的一部分,应该是 GPL 许可证文本的片段。
异常数据特征分析
通过对多次请求的原始响应进行比对,总结出以下异常特征:
| 特征 |
表现 |
出现频率 |
| 裸文本行 |
无 data: 前缀的纯文本 |
每次 Tool Call 必现 |
| 内容来源 |
Tool Call arguments 字符串片段 |
100% |
| 出现位置 |
tool_calls delta 输出过程中 |
100% |
| content 输出 |
正常 SSE 格式 |
不受影响 |
| finish_reason |
正确返回 tool_calls |
不受影响 |
| JSON 完整性 |
tool_call JSON 被截断 |
100% |
这些特征表明问题具有高度一致性,并非网络传输错误或偶发异常,而是服务端实现层面的系统性问题。
根因分析
问题本质:协议层混流
该问题的本质是服务端在流式输出过程中,存在两条独立的输出通道被错误地混合写入同一个 HTTP 响应流:
| 输出类型 |
预期通道 |
实际通道 |
状态 |
| 普通内容 (content) |
SSE JSON 封装 |
SSE JSON 封装 |
✅ 正常 |
| Tool Call 参数 (arguments) |
SSE JSON 封装 |
原始 stdout 直写 |
❌ 异常 |
正常情况下,所有输出内容都应当经过 SSE 协议封装层,统一格式化为
data: {...} 后写入 HTTP 响应体。但实际实现中,Tool Call 的 arguments 生成逻辑绕过了封装层,直接将 token 写入输出流。
推测的错误实现
根据输出特征,推测服务端可能存在类似以下的错误实现:
// ❌ 错误实现示意
func streamToolCallArguments(tokens []string, writer http.ResponseWriter) {
for _, token := range tokens {
// 错误:直接写入原始 token,未经过 SSE 封装
writer.Write([]byte(token))
writer.(http.Flusher).Flush()
}
}
// ✅ 正确实现应当是
func streamToolCallArguments(tokens []string, writer http.ResponseWriter) {
for _, token := range tokens {
// 正确:构造完整的 SSE 消息
message := SSEMessage{
Choices: []Choice{
{
Delta: Delta{
ToolCalls: []ToolCall{
{
Function: Function{
Arguments: token,
},
},
},
},
},
},
}
fmt.Fprintf(writer, "data: %s\n\n", json.Marshal(message))
writer.(http.Flusher).Flush()
}
}
为什么会出现”半截字符串”
观察到的裸文本如
ts from you under 实际上是 arguments 字符串的中间片段。这是因为:
- Token 级流式输出:模型生成 arguments 时按 token 逐个输出,而非等待完整字符串生成后一次性输出
- 写入时机错误:每个 token 生成后立即被写入输出流,但未经过 JSON 转义和 SSE 封装
- 边界不匹配:token 切分点与 JSON 字符串边界不一致,导致输出的是字符串中间片段
例如,当 arguments 应该是
"GPL License text from you under this License" 时,实际输出可能是:
data: {"arguments":"GPL Licen"} // 正常 SSE
se text from you under // 裸文本(错误)
this License" // 裸文本(错误)
data: {"arguments":""} // 正常 SSE
通道混用的可能原因
从软件工程角度分析,这种问题通常由以下原因导致:
- 代码路径分支:content 输出和 tool_call 输出走了不同的代码路径,其中一条路径遗漏了封装逻辑
- 历史遗留代码:可能在迭代过程中,tool_call 功能是后期添加的,复用了旧的输出接口
- 测试覆盖不足:缺少对原始 HTTP 响应流的协议一致性测试,仅测试了最终解析结果
- 多团队协作:流式输出模块和 Tool Call 模块可能由不同团队开发,接口约定不清晰
对客户端的影响
chat.nvim 解析器行为
chat.nvim 的 SSE 解析器期望每一行输入都是合法的
data: {...} 格式,当遇到裸文本时会出现以下问题:
-- 简化的解析逻辑
local function parse_sse_line(line)
local data = line:sub(7) -- 去掉 "data: " 前缀
return vim.json.decode(data) -- 裸文本会导致这里报错
end
当输入行为
ts from you under 时:
line:sub(7) 返回 ` from you under`(或原样返回,如果行长度不足 7)
vim.json.decode() 尝试解析非 JSON 字符串,抛出异常
- 异常可能导致整个流式处理中断,Tool Call 无法完成
实际影响范围
| 影响项 |
严重程度 |
说明 |
| JSON 解析失败 |
高 |
vim.json.decode() 抛出异常 |
| Tool Call 识别失败 |
高 |
arguments 内容不完整,无法调用工具 |
| 流式输出中断 |
中 |
可能触发错误处理逻辑,提前终止 |
| 用户体验下降 |
中 |
功能不可用或响应异常 |
| 错误日志污染 |
低 |
产生大量解析错误日志 |
临时容错方案
在无法修改服务端的情况下,客户端可以增加容错逻辑,过滤非法数据行:
local function safe_parse_sse(line)
-- 严格过滤:只处理以 "data: " 开头的行
if not line:match("^data: ") then
vim.log.warn("跳过非法 SSE 行:" .. line)
return nil
end
local ok, result = pcall(function()
local content = line:sub(7)
if content == "[DONE]" then
return "DONE"
end
return vim.json.decode(content)
end)
if not ok then
vim.log.warn("JSON 解析失败:" .. result)
return nil
end
return result
end
-- 在 on_stdout 回调中使用
on_stdout = function(_, data)
for line in data:gmatch("[^\n]+") do
local parsed = safe_parse_sse(line)
if parsed then
handle_chunk(parsed)
end
end
end
该方案的核心思想是
严格过滤 + 静默丢弃,遇到非法数据行时记录警告日志但不中断处理流程。这样可以保证在服务端修复问题之前,客户端仍能正常工作。
调试与验证方法
保存原始响应
使用 curl 直接请求并保存完整响应,便于离线分析:
curl -N \
https://coding.dashscope.aliyuncs.com/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-d '{
"model": "glm-5",
"messages": [
{"role": "user", "content": "请读取当前目录下的 LICENSE 文件"}
],
"stream": true,
"tools": [
{
"type": "function",
"function": {
"name": "read_file",
"parameters": {
"type": "object",
"properties": {
"filepath": {"type": "string"}
}
}
}
}
]
}' \
> raw_response.txt
参数说明:
-N:禁用缓冲,实时输出
raw_response.txt:保存完整原始响应
分析响应内容
# 统计总行数
wc -l raw_response.txt
# 查看非 data: 开头的行(非法行)
grep -v "^data: " raw_response.txt
# 统计非法行数量
grep -cv "^data: " raw_response.txt
# 查看非法行的上下文(前后各 2 行)
grep -v "^data: " -B 2 -A 2 raw_response.txt
# 提取所有 data: 行并验证 JSON 格式
grep "^data: " raw_response.txt | \
sed 's/^data: //' | \
while read line; do
echo "$line" | jq . 2>/dev/null || echo "INVALID: $line"
done
验证 Tool Call 完整性
# 提取所有 tool_calls 相关的行
grep "tool_calls" raw_response.txt
# 检查 arguments 字段是否完整
grep "arguments" raw_response.txt | \
sed 's/^data: //' | \
jq '.choices[0].delta.tool_calls[0].function.arguments'
如果 arguments 字段显示不完整或解析失败,说明存在数据污染。
结论与建议
问题定性
该问题属于
服务端协议层实现缺陷,具体表现为:
- 非客户端问题:chat.nvim 的 SSE 解析逻辑符合 OpenAI 规范
- 非网络问题:裸文本稳定出现,非传输错误
- 非偶发问题:每次 Tool Call 必现,具有可复现性
- 协议级错误:违反 SSE 协议规范,混合输出不同格式数据
根本原因
Tool Call 的 arguments 在流式生成过程中未被正确封装进 SSE 消息格式,导致原始 token 直接泄漏到 HTTP 响应流中。这反映了服务端在流式输出模块和 Tool Call 模块的集成测试不足。
解决建议
对服务端(阿里云):
- 修复 arguments 输出路径,确保所有输出经过 SSE 封装层
- 增加协议一致性测试,验证响应流符合 SSE 规范
- 提供问题追踪和修复时间表
对客户端(chat.nvim):
- 增加 SSE 行过滤逻辑,容忍非法数据
- 记录详细的解析错误日志,便于问题诊断
- 考虑增加协议一致性检查,主动检测服务端问题
后续跟进
建议将原始响应日志提交给阿里云技术支持,附上:
- 完整的
raw_response.txt 文件
- 请求参数和调用时间
- 问题复现步骤
- 期望的正确输出格式
参考资料