From f535dbaa9ac2bd9b603b8b379e63d33b72d36f06 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Tue, 21 Jul 2026 08:16:54 +0800 Subject: [PATCH] =?UTF-8?q?fix(llm,loop):=20glm.pro52=20=E7=A9=BA=E5=93=8D?= =?UTF-8?q?=E5=BA=94=E6=B2=BB=E6=9C=AC=E2=80=94=E2=80=94=E7=A6=81=20thinki?= =?UTF-8?q?ng=20=E5=85=8D=E6=8E=A8=E7=90=86=E7=83=A7=E7=A9=BF=E8=BE=93?= =?UTF-8?q?=E5=87=BA=E4=B8=8A=E9=99=90=20+=20loop=20=E5=8C=BA=E5=88=86?= =?UTF-8?q?=E6=88=AA=E6=96=AD(bump=200.58.49)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 失败面板 empty_response 簇 2026-07 主角 glm.pro52。探针 + DB + 代码三重定层根因链: 1. caps.max_output 是全仓死字段(只在 capabilities.py 定义,_build_kwargs 从不作为 max_tokens 发出)→ 网关放任 glm-5.2 跑到自带 65536 输出硬上限; 2. glm-5.2 thinking 网关侧默认开(线上探针实测 reasoning_content=766>0,尽管 config thinking_mode:false —— 那开关是 glm.yaml 未做的 TODO,根本没透传); 3. 重任务(100k 上下文)上思考膨胀烧满 65536 被截断(finish_reason=length)、content 空 → loop 判空响应整轮丢弃 + 同上下文无效重试(task 35744bea:5 次 empty 全 tokens_out=65536,事件4=attempt2)。单任务烧 ~327k 输出 token。 与 opus48/deepseek 的网关 wire bug 不同根 —— 这是我方 max_output 死 + 未约束推理模型。 修(禁 thinking + loop 健壮化): - core/llm.py _build_kwargs 加 family=="glm" 分支,据 thinking_mode 透传 extra_body={"thinking":{"type":"enabled|disabled"}}(GLM body 级协议,与 OpenAI 的 reasoning_effort 不同族;litellm zai provider 转发 extra_body)。当前 glm 档均 false=disabled。 线上探针 A/B 实测:disabled 后 reasoning_content 766->0、正文/工具照常。 - core/loop.py 加 _finish_reason,空响应路径区分 length(截断,我方预算烧穿,重试无效)与 wire 吐空,warn 措辞据实;record_empty_response units 记 finish_reason(JSON 免 migration)。 - 修既坏的 test_loop_empty_response(mock 缺 executor)+ 补截断措辞用例,61 测试全绿。 - 探针 diag_glm_empty_probe.py 加 monkeypatch 绕沙箱池 + PROBE_THINKING_OFF A/B 开关。 行为变化(知情):glm.pro52 用户现在拿禁思考的 glm-5.2(config 本就 thinking_mode:false)—— 不再卡壳/空转,代价是硬任务少了推理链。遗留:max_output 对所有模型仍未生效(本次没盲发 max_tokens,怕截断大 write 的 args),需逐档评估安全值后另做。 Co-Authored-By: Claude Opus 4.8 (1M context) --- PROGRESS.md | 3 ++- config/models/glm.yaml | 7 +++++-- core/__init__.py | 2 +- core/llm.py | 10 ++++++++++ core/loop.py | 22 ++++++++++++++++++++-- core/storage/usage.py | 4 ++++ scripts/diag_glm_empty_probe.py | 16 ++++++++++++++++ tests/test_loop_empty_response.py | 20 +++++++++++++++++--- 8 files changed, 75 insertions(+), 9 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 1c689b4..eb7f38f 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。 -最后更新:2026-07-20(加 glm.pro52 空响应定层探针,交生产跑,bump 0.58.48) +最后更新:2026-07-21(glm.pro52 空响应定层+治本:禁 thinking 免推理烧穿输出上限 + loop 区分截断,bump 0.58.49) --- @@ -23,6 +23,7 @@ ### 2026-07 +- **07-21 / 0.58.49 / glm.pro52 空响应治本(禁 thinking + loop 区分截断)**:探针定层坐实根因链——①`max_output` 是**全仓死字段**(只在 capabilities.py 定义,`_build_kwargs` 零引用,`max_tokens` 从不发)→ 网关放任 glm-5.2 跑到自带 65536 输出上限;②glm-5.2 thinking **网关侧默认开**(线上探针实测 reasoning_content=766>0,尽管 config thinking_mode:false —— 那开关是 glm.yaml 未做的 TODO,根本没传);③重任务(100k 上下文)上思考膨胀烧穿 65536 被截断(finish_reason=length)、content 空 → loop 判空响应整轮丢弃 + **同上下文无效重试**(task 35744bea:5 次 empty 全 tokens_out=65536,事件4=attempt2 铁证);后果单任务烧 ~327k 输出 token。与 opus48/deepseek 网关 wire bug 不同根。**修**:`core/llm.py._build_kwargs` 加 family=="glm" 分支,据 thinking_mode 透传 `extra_body={"thinking":{"type":"enabled|disabled"}}`(GLM 协议,与 reasoning_effort 不同族)——线上探针 A/B 实测 disabled 后 reasoning_content 766→0、正文照常;`core/loop.py` 加 `_finish_reason`,空响应路径区分 length(截断,我方预算烧穿,重试无效)与 wire 吐空,warn 措辞据实 + `record_empty_response` units 记 finish_reason(JSON 免 migration,便于面板区分性质)。修好既坏的 test_loop_empty_response(mock 缺 executor)+ 补截断措辞用例,61 测试全绿。探针 `scripts/diag_glm_empty_probe.py` 留仓(monkeypatch _resolve_executor 绕沙箱池、PROBE_THINKING_OFF A/B 开关)。**遗留**:max_output 对所有模型仍未生效(本次只治 glm thinking 根因,没盲发 max_tokens——怕截断大 write 的 args);要不要让 max_output 全局生效需单独评估各档安全值。 - **07-20 / 0.58.48 / glm.pro52 空响应定层探针**:失败面板 empty 簇(15)主角从 opus48 换成 `glm.pro52`(11/15)。加 `scripts/diag_glm_empty_probe.py`——`build_agent(resume=True)` 复原真实 system prompt + 全工具 schema + **完整多轮上下文**(空响应发生在深层,单条 user 消息复现不了,这是与 2a1bc25d narrated 探针的关键差别),打 glm.pro52,三态判定 EMPTY(真吐空)/NARRATED(工具意图漏成正文)/STRUCTURED。默认锁 task 35744bea(近7天 empty 5 次最可复现)。本机 import litellm 卡 ~20min,交生产机 `.venv/Scripts/python.exe scripts/diag_glm_empty_probe.py` 跑,输出写 `scripts/_glm_empty_probe_*.txt`。定层后再决处置。 - **07-20 / 0.58.47 / 工具失败面板 6 簇巡检 + svg 质量门签名诚实化**:扫近7天失败聚集逐簇定根因。①**write/edit malformed(45/32)**=100% DeepSeek v4 wire 把并行 tool_call 的 arguments 拼成 `{...}{...}`,salvage(0.58.24)复盘提前到 07-20:命中率稳 ~85%(post-launch salvaged 252/malformed 44)、残差涉事 task 全在跑无终态失败=落非流式重试自愈,**决策保持现状不再写代码**(面板绝对数被上线前 07-13/14 尾巴撑高,要看 salvaged/malformed 比)。②**shell/exit "Font issues"(20)**=`svg_quality_checker` 质量门按设计 exit-1,但 `toolfail._classify` 取 `[exit]` 前尾行当签名,而尾行永远是通用 tip「4. Font issues:…」→ 所有门失败(typography/alignment/spec_lock drift)误标成 Font issues 误导排查。修:checker 在 errors>0 时末尾多打确定性 `gate_verdict_line()`「[GATE FAIL] svg_quality_checker: N error file(s) / M total; top issues: …」作为最后一行 stdout(放 main() 里 export 之后),toolfail 现有取尾行逻辑自动拾取诚实签名、toolfail 零改动;`_categorize_issue` 加 `spec_lock/typography` 类避免真错落 "Other"。补 3 测试(GateVerdictLineTests),39+3 全绿。③**run_python 预检(31)**=pysyntax 按设计拦中文正文硬拼进 .py(引号/缩进崩),非平台 bug=提示词治理待办。④**empty(15)**=主角换成 `glm.pro52`(11/15,非旧 opus48 案),需生产跑 narrated 探针定层(本机 import litellm 卡,task e7c2f50b 备用)。⑤**web_fetch(6)**=外部 403 拒抓为主,非平台锅。 - **07-20 / 0.58.45 / glob 绝对路径崩溃(工具失败聚集 #2)**:巡检近7天工具失败聚集,`glob` 排第2(3次/3task/3用户、近24h 仍在发)。根因:`tools/fs.py` GlobTool 直接 `base.glob(pattern)`,模型传绝对 pattern(沙箱 cwd 就是 `/workspace`,自然写 `/workspace/**/*.py`)时 pathlib 抛 `NotImplementedError: Non-relative patterns are unsupported`,整个调用崩返 `[Error executing glob]`。改:绝对 pattern 先 `_split_abs_glob` 拆成「存在的静态前缀目录 + 相对 glob 串」(左起吃掉不含 `*?[` 的组件当 base)再匹配;补首字符 `/`\`\\` 判定(POSIX 前导斜杠在 Windows 宿主 `is_absolute()` 为 False,沙箱是 Linux,两处对齐)。顺手删掉原 `if "**"`/`else` 两分支代码完全相同的死重构。`/sandbox/tools/` 是 Dockerfile `COPY tools/` 拷贝,**需重建沙箱镜像**才生效。 diff --git a/config/models/glm.yaml b/config/models/glm.yaml index 176c80a..b24e9fb 100644 --- a/config/models/glm.yaml +++ b/config/models/glm.yaml @@ -1,8 +1,11 @@ # 智谱 GLM 模型档案 # 走 litellm 原生 zai provider(1.83+ 内置)。litellm 默认 api_base 是国际站 api.z.ai; # 国内站 bigmodel.cn 通过 yaml 的 api_base 字段覆盖。两站 API key 不通用,env 也分开命名。 -# thinking 暂不接:GLM 的 thinking 协议是 body `{"type":"enabled"}`,与 OpenAI/DeepSeek -# 的 `reasoning_effort` 等级不同,要 core/llm.py 加 family 分支才能透传 — 留 TODO。 +# thinking 已接(core/llm.py _build_kwargs 的 family=="glm" 分支):GLM 协议是 body +# `{"thinking":{"type":"enabled|disabled"}}`(与 OpenAI/DeepSeek 的 `reasoning_effort` +# 等级不同族),走 extra_body 透传,由本档 `thinking_mode` 决定开关。当前均 false=禁用 —— +# 因网关侧默认开 thinking,重任务上会把输出预算烧在 reasoning_content 上撞满 65536 上限 +# 被截断→空响应(task 35744bea 案);禁用后线上探针实测 reasoning_content 归零、正文照常。 family: glm variants: diff --git a/core/__init__.py b/core/__init__.py index 2bbcfec..554a919 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.58.48" +__version__ = "0.58.49" diff --git a/core/llm.py b/core/llm.py index 41e55bc..dfbda66 100644 --- a/core/llm.py +++ b/core/llm.py @@ -66,6 +66,16 @@ class LLM: kwargs["parallel_tool_calls"] = True if self.caps.thinking_mode and reasoning_effort: kwargs["reasoning_effort"] = reasoning_effort + # GLM(zai)的 thinking 网关侧默认开着 —— 重任务上把整个输出预算烧在 reasoning_content + # 上、撞满模型自带输出上限(65536)被截断,回来 content 空 + 无 tool_call,被 loop 判空 + # 响应整轮丢弃 + 无效重试(task 35744bea 案:5 次 empty 全 tokens_out=65536,重试同上下文 + # 再撞)。GLM 的 thinking 协议是 body {"thinking":{"type":...}}(与 OpenAI 的 + # reasoning_effort 等级协议不同族),走 extra_body 透传 —— 线上探针实测 disabled 后 + # reasoning_content 归零、正文/工具照常。config thinking_mode 决定开关(当前 glm 档均 false)。 + if self.caps.family == "glm": + kwargs["extra_body"] = { + "thinking": {"type": "enabled" if self.caps.thinking_mode else "disabled"} + } if self.caps.prompt_caching: kwargs["extra_headers"] = {"anthropic-beta": "prompt-caching-2024-07-31"} return kwargs diff --git a/core/loop.py b/core/loop.py index ef751ec..b319366 100644 --- a/core/loop.py +++ b/core/loop.py @@ -338,8 +338,18 @@ def _is_empty_response(response: Any) -> bool: return not content.strip() +def _finish_reason(response: Any) -> str: + """取本轮 finish_reason(取不到返 "")。length=达输出上限被截断,与 wire 吐空区分开: + 截断是我方输出预算/推理失控(如 GLM thinking 烧穿),同上下文重试无效。""" + try: + return getattr(response.choices[0], "finish_reason", "") or "" + except (AttributeError, IndexError, TypeError): + return "" + + def _log_empty_response( task_id: Any, user_id: Any, model_profile: str, response: Any, attempt: int, + finish_reason: str = "", ) -> None: """空响应留痕:stdout + usage_events(kind=empty_response)双写,任何一路失败都静默。 @@ -351,7 +361,7 @@ def _log_empty_response( usage = _extract_usage_details(getattr(response, "usage", None)) print( f"[empty_response] task={task_id} mp={model_profile} attempt={attempt} " - f"tok={usage['tokens_in']}/{usage['tokens_out']}", + f"finish={finish_reason or '?'} tok={usage['tokens_in']}/{usage['tokens_out']}", flush=True, ) try: @@ -362,6 +372,7 @@ def _log_empty_response( attempt=attempt, tokens_in=usage["tokens_in"], tokens_out=usage["tokens_out"], + finish_reason=finish_reason, ) except Exception: pass # DB 不可用(如单测无 DB)不影响 stdout 留痕与重试 @@ -742,14 +753,21 @@ class AgentLoop: # 空响应(tc 空且正文空):provider wire 吐空,和畸形同类瞬态故障 —— # 丢弃本轮走非流式重试(多数瞬态重发一次即好,用户无感),留痕供面板可见。 if _is_empty_response(response): + fr = _finish_reason(response) _log_empty_response( self.session.task_id, self.user_id, f"{self.caps.family}.{self.caps.variant}", response, attempt + 1, + finish_reason=fr, ) + # length=达输出上限被截断(我方输出预算/推理烧穿,非网关 wire 吐空)—— + # 同上下文重试大概率再撞,措辞据实区分,便于用户/日志判性质(治本在 + # 模型档,如 GLM 已禁 thinking 免推理烧穿;这里保证可观测 + 不误导)。 + truncated = fr == "length" self._emit({ "type": "warn", "msg": ( - "模型返回空响应,丢弃本轮" + ("模型输出达上限被截断" if truncated else "模型返回空响应") + + ",丢弃本轮" f"{'重试' if use_nonstream else ',改非流式重试'}" f" ({attempt + 1}/{self._MAX_MALFORMED_ATTEMPTS})" ), diff --git a/core/storage/usage.py b/core/storage/usage.py index ec06a6e..4b14054 100644 --- a/core/storage/usage.py +++ b/core/storage/usage.py @@ -233,6 +233,7 @@ def record_empty_response( attempt: int, tokens_in: int = 0, tokens_out: int = 0, + finish_reason: str = "", ) -> None: """记一次 provider 吐空(kind=empty_response):assistant 轮既无 tool_calls 又无正文。 @@ -254,6 +255,9 @@ def record_empty_response( "attempt": int(attempt), "tokens_in": int(tokens_in), "tokens_out": int(tokens_out), + # finish_reason 区分「网关 wire 吐空」(stop/其他)与「输出达上限被截断」 + # (length)—— 后者是我方输出预算/推理失控,同上下文重试无效(见 loop 处理)。 + "finish_reason": finish_reason or "", }, cost_cny=Decimal("0"), )) diff --git a/scripts/diag_glm_empty_probe.py b/scripts/diag_glm_empty_probe.py index f9891e3..5128f21 100644 --- a/scripts/diag_glm_empty_probe.py +++ b/scripts/diag_glm_empty_probe.py @@ -32,8 +32,17 @@ os.environ.setdefault("LITELLM_LOCAL_MODEL_COST_MAP", "True") sys.path.insert(0, str(ROOT)) import litellm # noqa: E402 +import core.agent_builder as _ab # noqa: E402 from core.agent_builder import build_agent # noqa: E402 +# 探针只读:不跑任何工具,只需 executor.schemas() —— docker 与 host 的 schema 完全一致 +# (executor_docker.schemas() 直接 `return self.host.schemas()`)。独立脚本没跑 web +# lifespan 的 init_pool(),docker backend 下 _resolve_executor 会因 pool=None 抛 +# RuntimeError。故 monkeypatch 它返回 host executor 绕开沙箱池;ZCBOT_SANDBOX_BACKEND +# 仍留 docker,让 _build_system_prompt 的 is_docker=True → 保留容器 env 块 + 容器路径, +# 复现保真(system prompt / tools 与线上逐字一致,只是不真起容器)。 +_ab._resolve_executor = lambda host, *a, **k: host + # 默认锁定 35744bea(近7天 empty 5 次、最可复现);可 argv 覆盖。 TASK_ID = sys.argv[1] if len(sys.argv) > 1 else "35744bea-49c0-41c8-86be-8d0c5c05bd32" USER_ID = UUID(sys.argv[2]) if len(sys.argv) > 2 else UUID("3cc07a22-f667-47f3-9617-e7d8a1a60cf0") @@ -80,6 +89,11 @@ kwargs_base = dict( ) if caps.parallel_tools: kwargs_base["parallel_tool_calls"] = True +# A/B 开关:PROBE_THINKING_OFF=1 透传 GLM 的 thinking 禁用(验证「禁 thinking 能否让 +# reasoning_content 归零、避免输出预算被推理烧穿」这条修法)。默认不带 = 复现线上现状。 +if os.getenv("PROBE_THINKING_OFF") == "1": + kwargs_base["extra_body"] = {"thinking": {"type": "disabled"}} + print("[probe] thinking DISABLED via extra_body", file=sys.stderr) stdout_lines = [] @@ -103,7 +117,9 @@ def probe(tag, stream): content = msg.content or "" fr = resp.choices[0].finish_reason u = resp.usage + rc = getattr(msg, "reasoning_content", None) or "" w(f" finish_reason={fr}") + w(f" reasoning_content chars={len(rc)} (>0 = 该档 thinking 是开的)") w(f" usage prompt={u.prompt_tokens} completion={u.completion_tokens}") w(f" tool_calls count={len(tc)}") for i, t in enumerate(tc): diff --git a/tests/test_loop_empty_response.py b/tests/test_loop_empty_response.py index 34f7701..8e41306 100644 --- a/tests/test_loop_empty_response.py +++ b/tests/test_loop_empty_response.py @@ -10,10 +10,12 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from core.loop import AgentLoop, _is_empty_response # noqa: E402 -def _resp_empty(content=None): - """空响应桩:无 tool_calls,content 为空/None/空白。""" +def _resp_empty(content=None, finish_reason=None): + """空响应桩:无 tool_calls,content 为空/None/空白。finish_reason 可选 + (length=达输出上限被截断,与 wire 吐空区分)。""" msg = SimpleNamespace(tool_calls=[], content=content) - return SimpleNamespace(choices=[SimpleNamespace(message=msg)], usage=None) + choice = SimpleNamespace(message=msg, finish_reason=finish_reason) + return SimpleNamespace(choices=[choice], usage=None) def _resp_text(text): @@ -34,6 +36,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_empty_response 落库路径静默跳过 + loop.executor = SimpleNamespace(schemas=lambda: []) # required_by_tool 取值用,空即可 loop.events = [] loop._emit = loop.events.append calls = {"stream": 0, "nonstream": 0} @@ -97,6 +100,17 @@ class TestEmptyResponseRetry(unittest.TestCase): warns = [e for e in loop.events if e.get("type") == "warn"] self.assertEqual(len(warns), AgentLoop._MAX_MALFORMED_ATTEMPTS) + def test_truncation_length_distinct_message(self): + """finish_reason=length(达输出上限被截断)措辞与 wire 吐空区分 —— warn 说「截断」不说「空响应」。""" + loop, calls = _make_loop( + [_resp_empty(finish_reason="length")], [_resp_text("ok")]) + resp, cancelled = loop._stream_llm() + self.assertFalse(cancelled) + warns = [e for e in loop.events if e.get("type") == "warn"] + self.assertEqual(len(warns), 1) + self.assertIn("截断", warns[0]["msg"]) + self.assertNotIn("空响应", warns[0]["msg"]) + def test_clean_text_no_retry(self): """正常文本收尾轮不触发重试。""" loop, calls = _make_loop([_resp_text("done")], [])