fix(llm): 稳定 DeepSeek 大参数工具传输

This commit is contained in:
caoqianming 2026-08-21 14:15:10 +08:00
parent 0a010c7ac3
commit d2da9b243f
3 changed files with 145 additions and 11 deletions

View File

@ -17,13 +17,22 @@
from __future__ import annotations from __future__ import annotations
import json import json
from typing import Any, Callable, Dict, List, Optional, Tuple from collections.abc import Callable
from typing import Any, Dict, List, Optional, Tuple
from .storage import record_empty_response, record_malformed_tool_call from .storage import record_empty_response, record_malformed_tool_call
# ─────────────────────── delta / usage 提取 ─────────────────────── # ─────────────────────── delta / usage 提取 ───────────────────────
class PreferNonstreamToolCall(Exception):
"""流式首包已选中不适合分片传输的工具,请调用方改走非流式。"""
def __init__(self, tool: str) -> None:
super().__init__(tool)
self.tool = tool
def extract_delta_content(chunk: Any) -> Optional[str]: def extract_delta_content(chunk: Any) -> Optional[str]:
"""从 stream chunk 提 delta.content(文本片段)。chunk 形态 litellm ModelResponseStream: """从 stream chunk 提 delta.content(文本片段)。chunk 形态 litellm ModelResponseStream:
choices[0].delta.contentusage-only 收尾 chunk( choices / delta) None choices[0].delta.contentusage-only 收尾 chunk( choices / delta) None
@ -62,6 +71,29 @@ def extract_delta_reasoning(chunk: Any) -> Optional[str]:
return None return None
def extract_delta_tool_names(chunk: Any) -> List[str]:
"""提取本 chunk 新出现的 tool call function name兼容对象和 dict 形态。"""
try:
choices = getattr(chunk, "choices", None)
if not choices:
return []
delta = getattr(choices[0], "delta", None)
if delta is None:
return []
tool_calls = getattr(delta, "tool_calls", None) or []
names: List[str] = []
for tc in tool_calls:
fn = tc.get("function") if isinstance(tc, dict) else getattr(tc, "function", None)
if fn is None:
continue
name = fn.get("name") if isinstance(fn, dict) else getattr(fn, "name", None)
if name:
names.append(str(name))
return names
except Exception:
return []
def usage_to_dict(usage: Any) -> dict: def usage_to_dict(usage: Any) -> dict:
if not usage: if not usage:
return {} return {}
@ -375,7 +407,22 @@ def robust_stream(
# 非流式重试期间用户点了停止(线程级 poll,见 loop._nonstream_once) # 非流式重试期间用户点了停止(线程级 poll,见 loop._nonstream_once)
return None, True return None, True
else: else:
cancelled = False
try:
response, cancelled = collect_stream(llm_messages) response, cancelled = collect_stream(llm_messages)
except PreferNonstreamToolCall as reroute:
# function.name 通常早于大段 arguments 到达。此时立刻关流并由 provider
# 一次性拼好 JSON可避开 DeepSeek 长 write/edit 参数的 delta 错位。
emit({
"type": "warn",
"level": "info",
"msg": f"检测到大参数工具 {reroute.tool},已切换稳定传输模式",
})
emit(dict(llm_start_event))
response = nonstream(llm_messages)
if response is None:
return None, True
use_nonstream = True
if cancelled: if cancelled:
return None, True return None, True

View File

@ -16,38 +16,39 @@ import threading
import time import time
from pathlib import Path from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple from typing import Any, Callable, Dict, List, Optional, Tuple
from uuid import UUID from uuid import UUID
import litellm import litellm
from . import pptx_guard
from .artifacts import MAX_ARTIFACTS_PER_MESSAGE
from .capabilities import ModelCapabilities from .capabilities import ModelCapabilities
from .context import ( from .context import (
CHARS_PER_TOKEN, CHARS_PER_TOKEN,
COMPACT_CONTEXT_RATIO, COMPACT_CONTEXT_RATIO,
calibrated_chars_per_token, calibrated_chars_per_token,
filter_reasoning_for_replay,
clamp_ratio, clamp_ratio,
filter_reasoning_for_replay,
prepare_messages_with_stats, prepare_messages_with_stats,
) )
from .context_fold import maybe_fold from .context_fold import maybe_fold
from .executor import ExecCtx, Executor from .executor import ExecCtx, Executor
from .artifacts import MAX_ARTIFACTS_PER_MESSAGE
from .llm import LLM from .llm import LLM
from .llm_transport import ( from .llm_transport import (
PreferNonstreamToolCall,
extract_delta_content, extract_delta_content,
extract_delta_reasoning, extract_delta_reasoning,
extract_delta_tool_names,
extract_usage_details, extract_usage_details,
robust_stream, robust_stream,
) )
from .salvage import salvage_tool_arguments from .salvage import salvage_tool_arguments
from .session import Session from .session import Session
from .task_actions import DeferredTaskActions
from .storage import ( from .storage import (
record_chat_usage, record_chat_usage,
record_salvaged_tool_call, record_salvaged_tool_call,
) )
from . import pptx_guard from .task_actions import DeferredTaskActions
# 产物机检只挂能落盘的执行类工具(fs 写工具不适合造 pptx,机检无意义) # 产物机检只挂能落盘的执行类工具(fs 写工具不适合造 pptx,机检无意义)
_PPTX_GUARD_TOOLS = ("shell", "run_python") _PPTX_GUARD_TOOLS = ("shell", "run_python")
@ -431,6 +432,11 @@ class AgentLoop:
# 流式 delta 错位),历史数据里非流式兜底从未再畸形。 # 流式 delta 错位),历史数据里非流式兜底从未再畸形。
_MAX_MALFORMED_ATTEMPTS = 3 _MAX_MALFORMED_ATTEMPTS = 3
# DeepSeek 的长 write/edit arguments 在流式 delta 中偶发错位。function.name 首包
# 到达时立即关流并非流式重发;正文和其他工具仍走流式。只限定已实证的模型族,
# 避免把其他 provider 的正常工具调用无端降级。
_DEEPSEEK_NONSTREAM_TOOLS = frozenset({"write", "edit"})
# 连续多少步「整步无净产出」(全是 [Error]/重复结果/被拦)就判定空转、主动停。 # 连续多少步「整步无净产出」(全是 [Error]/重复结果/被拦)就判定空转、主动停。
# 比 max_iterations 早得多掐死死循环(第 8 步 vs 第 120 步),同时放正经长任务自由跑。 # 比 max_iterations 早得多掐死死循环(第 8 步 vs 第 120 步),同时放正经长任务自由跑。
# 保守取 8:几乎不误伤"连踩几个错再纠正"的正常波动,配 _RepeatGuard 逐指纹 HARD=4 双保险。 # 保守取 8:几乎不误伤"连踩几个错再纠正"的正常波动,配 _RepeatGuard 逐指纹 HARD=4 双保险。
@ -617,23 +623,44 @@ class AgentLoop:
cancel_check=self._is_cancelled, cancel_check=self._is_cancelled,
) )
cancelled = False cancelled = False
pending_events: List[dict] = []
may_reroute = self.caps.family == "deepseek_v4"
output_route_known = not may_reroute
try: try:
for chunk in stream: for chunk in stream:
if self._is_cancelled(): if self._is_cancelled():
cancelled = True cancelled = True
break break
chunks.append(chunk) chunks.append(chunk)
tool_names = extract_delta_tool_names(chunk)
if (
may_reroute
and any(name in self._DEEPSEEK_NONSTREAM_TOOLS for name in tool_names)
):
# 推理 delta 先暂存,避免切换后非流式完整 reasoning 再发一次造成重复。
raise PreferNonstreamToolCall(next(
name for name in tool_names
if name in self._DEEPSEEK_NONSTREAM_TOOLS
))
# delta.content 即时 emit 给前端打字机渲染;tool_call delta 不实时发 # delta.content 即时 emit 给前端打字机渲染;tool_call delta 不实时发
# (拼接散在多 chunk 跨 frame 难看,等拼回后整条 tool_call 事件由 # (拼接散在多 chunk 跨 frame 难看,等拼回后整条 tool_call 事件由
# _execute_tool_call 时机发更直观)。 # _execute_tool_call 时机发更直观)。
delta_text = extract_delta_content(chunk) delta_text = extract_delta_content(chunk)
if delta_text: if delta_text:
self._emit({"type": "text", "delta": delta_text}) pending_events.append({"type": "text", "delta": delta_text})
# thinking 模型的推理 delta 也实时流出(reasoning 事件):深度推理可达 # thinking 模型的推理 delta 也实时流出(reasoning 事件):深度推理可达
# 分钟级,不发的话前端全程静止"思考中",用户以为卡死。 # 分钟级,不发的话前端全程静止"思考中",用户以为卡死。
delta_reasoning = extract_delta_reasoning(chunk) delta_reasoning = extract_delta_reasoning(chunk)
if delta_reasoning: if delta_reasoning:
self._emit({"type": "reasoning", "delta": delta_reasoning}) pending_events.append({"type": "reasoning", "delta": delta_reasoning})
# 首个正文或任意工具名已确定本轮不会再切换;释放之前暂存的推理片段,
# 后续 chunk 也继续即时释放。只有尚未看到输出类型时才短暂缓冲。
if delta_text or tool_names:
output_route_known = True
if output_route_known and pending_events:
for event in pending_events:
self._emit(event)
pending_events.clear()
# interruptible stream 会在无新 chunk 的等待期直接因 cancel 结束迭代; # interruptible stream 会在无新 chunk 的等待期直接因 cancel 结束迭代;
# 循环体没有机会执行上面的检查,故在正常耗尽处再判一次。 # 循环体没有机会执行上面的检查,故在正常耗尽处再判一次。
if self._is_cancelled(): if self._is_cancelled():
@ -647,6 +674,10 @@ class AgentLoop:
if cancelled: if cancelled:
return None, True return None, True
# 极少数 provider 只有 reasoning/空收尾、始终没有正文或工具名,不能吞掉已收内容。
for event in pending_events:
self._emit(event)
# 用 litellm 官方 helper 拼回完整 response(包括 tool_calls 拼接 + usage)。 # 用 litellm 官方 helper 拼回完整 response(包括 tool_calls 拼接 + usage)。
# messages 参数仅用于失败时回填 prompt token 估算,正常路径 stream_options.include_usage # messages 参数仅用于失败时回填 prompt token 估算,正常路径 stream_options.include_usage
# 已让最后一个 chunk 带准确 usage。 # 已让最后一个 chunk 带准确 usage。

View File

@ -9,7 +9,12 @@ from types import SimpleNamespace
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from core.llm_transport import malformed_tool_calls as _malformed_tool_calls # noqa: E402 from core.llm_transport import ( # noqa: E402
PreferNonstreamToolCall,
)
from core.llm_transport import (
malformed_tool_calls as _malformed_tool_calls,
)
from core.loop import AgentLoop # noqa: E402 from core.loop import AgentLoop # noqa: E402
@ -51,6 +56,57 @@ def _make_loop(stream_results, nonstream_results):
class TestMalformedRetry(unittest.TestCase): class TestMalformedRetry(unittest.TestCase):
def test_deepseek_write_name_aborts_before_buffered_reasoning_is_emitted(self):
"""真实 collect 路径见到 write 名即关流,之前的 reasoning 不重复上屏。"""
reasoning = SimpleNamespace(
choices=[SimpleNamespace(delta=SimpleNamespace(
reasoning_content="thinking", content=None, tool_calls=None,
))]
)
write = SimpleNamespace(
choices=[SimpleNamespace(delta=SimpleNamespace(
reasoning_content=None,
content=None,
tool_calls=[SimpleNamespace(
function=SimpleNamespace(name="write", arguments=""),
)],
))]
)
loop = object.__new__(AgentLoop)
loop.caps = SimpleNamespace(
family="deepseek_v4", default_reasoning_effort=None,
)
loop.executor = SimpleNamespace(schemas=lambda: [])
loop.cancel_check = None
loop.events = []
loop._emit = loop.events.append
loop.llm = SimpleNamespace(
chat_stream=lambda **_kwargs: iter([reasoning, write]),
)
with self.assertRaises(PreferNonstreamToolCall):
loop._collect_stream_once([])
self.assertEqual(loop.events, [])
def test_preferred_tool_reroutes_before_arguments_stream(self):
"""首包识别 write/edit 后直接非流式,不把它算作一次畸形失败。"""
loop, calls = _make_loop([], [_resp(GOOD)])
def prefer_nonstream(_messages):
calls["stream"] += 1
raise PreferNonstreamToolCall("write")
loop._collect_stream_once = prefer_nonstream
resp, cancelled = loop._stream_llm()
self.assertFalse(cancelled)
self.assertEqual(_malformed_tool_calls(resp), [])
self.assertEqual(calls, {"stream": 1, "nonstream": 1})
infos = [e for e in loop.events if e.get("level") == "info"]
self.assertEqual(len(infos), 1)
self.assertIn("write", infos[0]["msg"])
starts = [e for e in loop.events if e.get("type") == "llm_start"]
self.assertEqual(len(starts), 2)
def test_clean_stream_no_retry(self): def test_clean_stream_no_retry(self):
loop, calls = _make_loop([_resp(GOOD)], []) loop, calls = _make_loop([_resp(GOOD)], [])
resp, cancelled = loop._stream_llm() resp, cancelled = loop._stream_llm()