fix(llm): stream DeepSeek reasoning immediately

This commit is contained in:
caoqianming 2026-08-31 16:03:27 +08:00
parent e05de152ad
commit 0d5cbb8407
7 changed files with 71 additions and 22 deletions

View File

@ -8,6 +8,8 @@
## Unreleased
- DeepSeek Flash 的思考过程恢复实时显示,不再等到正文开始后才集中出现。
- 国内模型成本统计跟随最新公开价格更新DeepSeek 会按实际峰谷时段和缓存命中量计算,历史调价后的记录也可审计矫正。
- GLM-5.3 Flash 替换旧版 GLM 并向默认档位开放;上传图片会直接交给主模型理解,长工具任务可延续既有分析状态。

View File

@ -208,7 +208,7 @@ Admin GET /v1/admin/*(require_admin;overview + usage/models|users + storage/
Export GET /v1/tasks/{id}/export(docx)
```
**SSE 事件**:`run_start / llm_start / text{delta} / reasoning{delta}(thinking 模型推理流,前端灰色折叠卡)/ progress_snapshot{run_id,steps,waiting}(当前 user message 即 run 边界,从 messages 投影恢复)/ tool_call / tool_result(预览,完整走 DB)/ llm_end / model_switch / warn{msg}(熔断·重复拦截·折叠失败等运行时提醒)/ context_fold{phase,...}(§8.8 Phase 2 折叠 start/done)/ cancelled / error / done`。`task_progress` 每次提交完整步骤快照,前端整体替换;旧 `set_plan/update_step` 仅在历史投影时兼容。进度 dock 是运行状态而非历史消息:活跃时紧凑显示,正常完成后隐藏,`ask_user` 等待确认/取消/异常时折叠保留。fan-out:每订阅独立 queue;迟到订阅先从 PG 恢复当前 run 最新进度,终态迟到订阅立收 done。普通直播事件不持久化(messages 走 PG)。
**SSE 事件**:`run_start / llm_start / text{delta} / reasoning{delta}(thinking 模型推理流,前端灰色折叠卡)/ reasoning_reset(传输层放弃当前响应并重试时清除已直播推理)/ progress_snapshot{run_id,steps,waiting}(当前 user message 即 run 边界,从 messages 投影恢复)/ tool_call / tool_result(预览,完整走 DB)/ llm_end / model_switch / warn{msg}(熔断·重复拦截·折叠失败等运行时提醒)/ context_fold{phase,...}(§8.8 Phase 2 折叠 start/done)/ cancelled / error / done`。`task_progress` 每次提交完整步骤快照,前端整体替换;旧 `set_plan/update_step` 仅在历史投影时兼容。进度 dock 是运行状态而非历史消息:活跃时紧凑显示,正常完成后隐藏,`ask_user` 等待确认/取消/异常时折叠保留。fan-out:每订阅独立 queue;迟到订阅先从 PG 恢复当前 run 最新进度,终态迟到订阅立收 done。普通直播事件不持久化(messages 走 PG)。
**版本化**:`/v1` minor 半年兼容,major 6 个月 deprecation。**CORS**:本地 `*`,部署收紧。
### 7.3 认证

View File

@ -639,9 +639,8 @@ class AgentLoop:
cancel_check=self._is_cancelled,
)
cancelled = False
pending_events: List[dict] = []
may_reroute = self.caps.family == "deepseek_v4"
output_route_known = not may_reroute
reasoning_emitted = False
try:
for chunk in stream:
if self._is_cancelled():
@ -653,7 +652,10 @@ class AgentLoop:
may_reroute
and any(name in self._DEEPSEEK_NONSTREAM_TOOLS for name in tool_names)
):
# 推理 delta 先暂存,避免切换后非流式完整 reasoning 再发一次造成重复。
# 当前流会被放弃并以非流式完整重发。已实时展示的推理属于被放弃
# 的响应,先通知前端清掉,避免随后展示最终响应时重复或与历史不一致。
if reasoning_emitted:
self._emit({"type": "reasoning_reset"})
raise PreferNonstreamToolCall(next(
name for name in tool_names
if name in self._DEEPSEEK_NONSTREAM_TOOLS
@ -663,20 +665,13 @@ class AgentLoop:
# _execute_tool_call 时机发更直观)。
delta_text = extract_delta_content(chunk)
if delta_text:
pending_events.append({"type": "text", "delta": delta_text})
self._emit({"type": "text", "delta": delta_text})
# thinking 模型的推理 delta 也实时流出(reasoning 事件):深度推理可达
# 分钟级,不发的话前端全程静止"思考中",用户以为卡死。
delta_reasoning = extract_delta_reasoning(chunk)
if 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()
self._emit({"type": "reasoning", "delta": delta_reasoning})
reasoning_emitted = True
# interruptible stream 会在无新 chunk 的等待期直接因 cancel 结束迭代;
# 循环体没有机会执行上面的检查,故在正常耗尽处再判一次。
if self._is_cancelled():
@ -690,10 +685,6 @@ class AgentLoop:
if cancelled:
return None, True
# 极少数 provider 只有 reasoning/空收尾、始终没有正文或工具名,不能吞掉已收内容。
for event in pending_events:
self._emit(event)
# 用 litellm 官方 helper 拼回完整 response(包括 tool_calls 拼接 + usage)。
# messages 参数仅用于失败时回填 prompt token 估算,正常路径 stream_options.include_usage
# 已让最后一个 chunk 带准确 usage。

View File

@ -35,6 +35,7 @@ def _make_loop(stream_results, nonstream_results):
loop.caps = SimpleNamespace(reliable_context=64_000, family="test", variant="t")
loop.session = SimpleNamespace(messages=[], task_id="test-task")
loop.user_id = "test-user" # 无 DB 环境:_log_malformed_args 的落库路径应静默跳过
loop.user_root = None
# salvage(0.58.24)接在畸形重试前:空 schemas → 未知工具无白名单 → _try_salvage 返 False,
# 这些用例本就测「salvage 救不了 → 落非流式重试」,给个空 executor 即可走到该分支。
loop.executor = SimpleNamespace(schemas=lambda: [])
@ -56,8 +57,8 @@ def _make_loop(stream_results, nonstream_results):
class TestMalformedRetry(unittest.TestCase):
def test_deepseek_write_name_aborts_before_buffered_reasoning_is_emitted(self):
"""真实 collect 路径见到 write 名即关流,之前的 reasoning 不重复上屏"""
def test_deepseek_write_name_resets_streamed_reasoning_before_reroute(self):
"""真实 collect 路径实时发 reasoning见到 write 后重置再改走非流式"""
reasoning = SimpleNamespace(
choices=[SimpleNamespace(delta=SimpleNamespace(
reasoning_content="thinking", content=None, tool_calls=None,
@ -86,7 +87,44 @@ class TestMalformedRetry(unittest.TestCase):
with self.assertRaises(PreferNonstreamToolCall):
loop._collect_stream_once([])
self.assertEqual(loop.events, [])
self.assertEqual(loop.events, [
{"type": "reasoning", "delta": "thinking"},
{"type": "reasoning_reset"},
])
def test_deepseek_reasoning_is_emitted_before_output_route_is_known(self):
"""普通 DeepSeek 回答不能等首个正文 token 才释放已经到达的推理。"""
reasoning = SimpleNamespace(
choices=[SimpleNamespace(delta=SimpleNamespace(
reasoning_content="thinking", content=None, tool_calls=None,
))]
)
text = SimpleNamespace(
choices=[SimpleNamespace(delta=SimpleNamespace(
reasoning_content=None, content="answer", tool_calls=None,
))]
)
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, text]),
)
with unittest.mock.patch(
"core.loop.litellm.stream_chunk_builder", return_value=object()
):
loop._collect_stream_once([])
self.assertEqual(loop.events, [
{"type": "reasoning", "delta": "thinking"},
{"type": "text", "delta": "answer"},
])
def test_preferred_tool_reroutes_before_arguments_stream(self):
"""首包识别 write/edit 后直接非流式,不把它算作一次畸形失败。"""

View File

@ -261,6 +261,13 @@ class StaticVendorTests(unittest.TestCase):
self.assertIn("await yieldToStreamPaint()", chat_js)
self.assertIn('document.visibilityState === "hidden"', chat_js)
def test_sse_consumer_resets_abandoned_reasoning_stream(self) -> None:
chat_js = (JS_DIR / "chat.js").read_text(encoding="utf-8")
self.assertIn("function resetReasonSeg(run)", chat_js)
self.assertIn('t === "reasoning_reset"', chat_js)
self.assertIn("resetReasonSeg(ctx)", chat_js)
def test_custom_task_name_is_optional_with_explicit_directory(self) -> None:
html = DEV_HTML.read_text(encoding="utf-8")
newtask_js = (JS_DIR / "newtask.js").read_text(encoding="utf-8")

View File

@ -636,7 +636,7 @@ def register_message_routes(app, *, require_user) -> None:
user_id: UUID = Depends(require_user),
):
"""SSE 流。订阅当前 task 的活动 event(单活 run 形态下无歧义)。
事件类型:run_start / llm_start / text / reasoning / tool_call /
事件类型:run_start / llm_start / text / reasoning / reasoning_reset / tool_call /
tool_result / llm_end / cancelled / error / donedata JSON dict
(已剔除 `type` 字段,移到 event )
"""

View File

@ -1403,6 +1403,15 @@ function closeReasonSeg(run) {
run.curReason = null;
}
// provider 传输层放弃当前流并重试时,移除这次响应已经直播出来的推理。
// 重试返回的最终 reasoning 会重新建立一段,确保直播态与最终持久化消息一致。
function resetReasonSeg(run) {
const rs = run.curReason;
if (!rs) return;
rs.el.remove();
run.curReason = null;
}
// ───── run 活跃状态指示(阶段 + 已耗时) ─────
// 长耗时阶段(TTFT / 深度推理 / 工具执行)原本页面全静止,和卡死无法区分。
// 每秒把「阶段 · Ns」写进空占位段的 data-status(CSS :empty::before 渲染),
@ -3433,6 +3442,8 @@ function handleSseEvent(ev, asstCard, ctx) {
if (nearBottom) stream.scrollTop = stream.scrollHeight;
});
}
} else if (t === "reasoning_reset") {
resetReasonSeg(ctx);
} else if (t === "text" && ev.data && ev.data.delta) {
closeReasonSeg(ctx); // 正文开始 → 思考过程定稿折叠
ctx.acc += ev.data.delta;