fix(llm): 稳定 DeepSeek 大参数工具传输
This commit is contained in:
parent
0a010c7ac3
commit
d2da9b243f
|
|
@ -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.content。usage-only 收尾 chunk(没 choices / delta)返 None。
|
choices[0].delta.content。usage-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
|
||||||
|
|
||||||
|
|
|
||||||
45
core/loop.py
45
core/loop.py
|
|
@ -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。
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue