fix(llm): stream DeepSeek reasoning immediately
This commit is contained in:
parent
e05de152ad
commit
0d5cbb8407
|
|
@ -8,6 +8,8 @@
|
|||
|
||||
## Unreleased
|
||||
|
||||
- DeepSeek Flash 的思考过程恢复实时显示,不再等到正文开始后才集中出现。
|
||||
|
||||
- 国内模型成本统计跟随最新公开价格更新,DeepSeek 会按实际峰谷时段和缓存命中量计算,历史调价后的记录也可审计矫正。
|
||||
|
||||
- GLM-5.3 Flash 替换旧版 GLM 并向默认档位开放;上传图片会直接交给主模型理解,长工具任务可延续既有分析状态。
|
||||
|
|
|
|||
|
|
@ -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 认证
|
||||
|
|
|
|||
25
core/loop.py
25
core/loop.py
|
|
@ -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。
|
||||
|
|
|
|||
|
|
@ -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 后直接非流式,不把它算作一次畸形失败。"""
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 / done。data 是 JSON dict
|
||||
(已剔除 `type` 字段,移到 event 名)。
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue