fix(lua): deepseek 适配器的流式路径处理 tool_calls(此前全部丢失)

## 缺陷

deepseek.lua 的 tool_calls 处理只存在于 `transform_response`(**非流式**路径),
而 `transform_stream_chunk` 只透 content/done:

    return json.encode({ content = delta.content or "", done = (fr ~= nil) })

于是 deepseek 源在**流式**模式下工具调用全部丢失 —— 模型调不动任何工具,
且**没有任何报错**,只是"工具好像不听话"。

## 为什么难发现

- 非流式路径是好的 ⇒ 端到端手工测试也过
- 内核的 tool call 循环默认走**流式**(provider.go 的 stream 分支)⇒ 实际不可用
- 功能判据(core 包的批内测试)直接构造 `agentAPI.StreamChunk{}`,
  **绕过适配器** ⇒ 测不到这一层

配置里 `deepseek` 源预设指向 `adapters/deepseek.lua`,所以任何按预设配置
的用户都会踩到(生产当前未启用该源,配置里 deepseek 相关键为 0)。

## 修法

照 openai.lua 的做法在流式路径补上:OpenAI 兼容格式
`{function:{name,arguments}, id, type, index}` → homed 扁平结构
`{id, type, name, raw_arguments, stream_index}`,含 reasoning_content 透传。

两个容易踩的点也写进注释:
- **不能按 name 过滤**:流式续传片 name 为空但携带 arguments,
  内核按 stream_index 分桶累积
- **必须透传 stream_index**:否则多个分片并到槽 0、argsRaw 混拼

## 判据

新增 TestDeepSeekAdapterHandlesStreamToolCalls:喂两个含 tool_call 的分片,
断言 tool_calls 未被丢弃且 stream_index 正确。修前两条分片全被丢弃。

## 体检分类随之变化

    修前: ✓ [openai]                                       ✗ [deepseek ...]
    修后: ✓ [deepseek openai]                              ✗ [anthropic gemini github groq mistral ollama]
This commit is contained in:
JianFeeeee
2026-09-27 18:31:42 +08:00
parent 17bc9a5553
commit 0fdb13749a
2 changed files with 106 additions and 2 deletions

View File

@ -89,10 +89,48 @@ function adapter.transform_stream_chunk(raw_chunk)
if not chunk.choices or #chunk.choices == 0 then return "" end
local delta = chunk.choices[1].delta or {}
local fr = chunk.choices[1].finish_reason
return json.encode({
local unified = {
content = delta.content or "",
done = (fr ~= nil)
})
}
if delta.reasoning_content then
unified.reasoning_content = delta.reasoning_content
end
-- ★ 必须处理流式 tool_calls —— 此前只透 content/done,导致 deepseek 源
-- 在**流式**模式下工具调用全部丢失,模型调不动任何工具且无任何报错。
--
-- 为什么难发现:非流式路径(transform_response)是好的,所以端到端
-- 手工测试也过;而内核的 tool call 循环默认走流式。
-- 功能判据(core 包的批内测试)直接构造 Go 结构体,绕过适配器。
--
-- 形态与 openai.lua 一致:OpenAI 兼容流式格式
-- {function:{name,arguments}, id, type, index} → homed 扁平结构
-- {id, type, name, raw_arguments, stream_index}。
if delta.tool_calls then
local tcs = {}
for _, tc in ipairs(delta.tool_calls) do
local fn = tc["function"]
local name = (type(fn) == "table" and fn.name) or tc.name or ""
local raw_args = ""
if type(fn) == "table" and type(fn.arguments) == "string" then
raw_args = fn.arguments
elseif type(tc.arguments) == "string" then
raw_args = tc.arguments
end
-- 不能按 name 过滤:流式续传片 name 为空但携带 arguments,
-- 内核 accumulateStream 按 stream_index 分桶并累积
table.insert(tcs, {
id = tc.id or "",
type = tc.type or "function",
name = name,
raw_arguments = raw_args,
-- 透传上游分片 index:并行多工具调用时内核按它区分归属桶
stream_index = tc.index or 0
})
end
unified.tool_calls = tcs
end
return json.encode(unified)
end
return adapter

View File

@ -0,0 +1,66 @@
package lua
import (
"encoding/json"
"testing"
)
// TestDeepSeekAdapterHandlesStreamToolCalls 钉住「deepseek 源的流式模式也能工具调用」。
//
// ★ 为什么单独给 deepseek 写判据:
//
// 它的 transform_stream_chunk 只透 content/done,**完全不处理 tool_calls**
// (那部分代码只存在于 transform_response,即非流式路径)。于是:
//
// · 非流式请求 ⇒ 工具调用正常
// · 流式请求 ⇒ 工具调用**全部丢失**,模型只收到纯文本
//
// 而内核的 tool call 循环默认走流式(provider.go 的 stream 分支)。所以
// 配了 deepseek 源的用户,模型调不动任何工具,且**没有任何报错** ——
// 只是"工具好像不听话"。
//
// 这类缺陷极难察觉:功能判据(core 包的批内测试)直接构造 Go 结构体,
// 完全绕过适配器;而非流式的端到端路径又是好的。
func TestDeepSeekAdapterHandlesStreamToolCalls(t *testing.T) {
vm := NewVM(t.TempDir())
loadBundled(t, vm, "deepseek")
// 一个含 2 个 tool_call 的 chunk(首片 + 续传片各一)
frags := []string{
`{"id":"c","choices":[{"index":0,"delta":{"tool_calls":[` +
`{"index":0,"id":"t0","type":"function","function":{"name":"cmd_run","arguments":"{}"}}]}}]}`,
`{"id":"c","choices":[{"index":0,"delta":{"tool_calls":[` +
`{"index":1,"id":"t1","type":"function","function":{"name":"cmd_run","arguments":"{}"}}]}}]}`,
}
for i, f := range frags {
out, err := vm.CallTransformStreamChunk("deepseek", f)
if err != nil {
t.Fatalf("第 %d 片: %v", i, err)
}
var u struct {
ToolCalls []struct {
StreamIndex int `json:"stream_index"`
ID string `json:"id"`
Name string `json:"name"`
RawArguments string `json:"raw_arguments"`
} `json:"tool_calls"`
}
if json.Unmarshal([]byte(out), &u) != nil {
t.Fatalf("第 %d 片输出非法: %s", i, out)
}
if len(u.ToolCalls) == 0 {
t.Errorf("第 %d 片:deepseek 适配器的流式路径**丢掉了 tool_call**\n"+
" 输出:%s\n"+
" ⇒ deepseek 源在流式模式下无法调用任何工具,且无任何报错。\n"+
" 它的 tool_calls 处理只存在于 transform_response(非流式路径)。",
i, out)
continue
}
if u.ToolCalls[0].StreamIndex != i {
t.Errorf("第 %d 片的 stream_index = %d,应为 %d", i, u.ToolCalls[0].StreamIndex, i)
}
if u.ToolCalls[0].Name != "cmd_run" {
t.Errorf("第 %d 片 name = %q,应为 cmd_run", i, u.ToolCalls[0].Name)
}
}
}