From 0fdb13749accd81736bd920cc96e3cdc6a0b89c7 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 27 Sep 2026 18:31:42 +0800 Subject: [PATCH] =?UTF-8?q?fix(lua):=20deepseek=20=E9=80=82=E9=85=8D?= =?UTF-8?q?=E5=99=A8=E7=9A=84=E6=B5=81=E5=BC=8F=E8=B7=AF=E5=BE=84=E5=A4=84?= =?UTF-8?q?=E7=90=86=20tool=5Fcalls=EF=BC=88=E6=AD=A4=E5=89=8D=E5=85=A8?= =?UTF-8?q?=E9=83=A8=E4=B8=A2=E5=A4=B1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 缺陷 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] --- internal/lua/adapters/deepseek.lua | 42 +++++++++++++++++- internal/lua/deepseek_stream_test.go | 66 ++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 internal/lua/deepseek_stream_test.go diff --git a/internal/lua/adapters/deepseek.lua b/internal/lua/adapters/deepseek.lua index dab4410..cc1811d 100644 --- a/internal/lua/adapters/deepseek.lua +++ b/internal/lua/adapters/deepseek.lua @@ -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 diff --git a/internal/lua/deepseek_stream_test.go b/internal/lua/deepseek_stream_test.go new file mode 100644 index 0000000..3832df9 --- /dev/null +++ b/internal/lua/deepseek_stream_test.go @@ -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) + } + } +}