feat(diag): 加 glm.pro52 空响应定层探针(交生产跑)(bump 0.58.48)

失败面板 empty 簇(近7天15)主角从 opus48 换成 glm.pro52(11/15):assistant 轮
既无 tool_calls 又无正文,被 loop 当正常收尾静默 done。要判 glm 是①真吐空、
②工具意图漏成正文(narrated)、还是③瞬态。

scripts/diag_glm_empty_probe.py:build_agent(resume=True) 复原真实 system prompt +
全工具 schema + 完整多轮上下文(空响应发生在深层,单条 user 消息复现不了 —— 与
2a1bc25d narrated 探针的关键差别),打 glm.pro52,stream/non-stream 各一发,
三态判定 EMPTY/NARRATED/STRUCTURED。默认锁 task 35744bea(近7天 empty 5 次
最可复现),argv 可换 task/user/档做对照。

本机 import litellm 卡 ~20min,只能交生产机跑:
  .venv/Scripts/python.exe scripts/diag_glm_empty_probe.py
输出写 scripts/_glm_empty_probe_*.txt。定层后再决处置。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-07-20 16:10:51 +08:00
parent 795161adcd
commit 9646f571f3
3 changed files with 137 additions and 2 deletions

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9` > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-07-20(工具失败面板巡检:salvage 复盘=保持 + svg 质量门签名诚实化,bump 0.58.47) 最后更新:2026-07-20(加 glm.pro52 空响应定层探针,交生产跑,bump 0.58.48)
--- ---
@ -23,6 +23,7 @@
### 2026-07 ### 2026-07
- **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.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/` 拷贝,**需重建沙箱镜像**才生效。 - **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/` 拷贝,**需重建沙箱镜像**才生效。
- **07-20 / 0.58.45 / PPT 页脚漂移治本(工具失败聚集 #1,A+C)**:`shell/exit` 排第1(近7天16条 exit-1、近24h 6条,9task/7用户),实测拆解 15/16 是**逐页手写绝对坐标偏离 `spec_lock.layout_grid` 锁定值 216px 的「想对齐没对齐」**(footer_y 近失 27行为最大头,margin_x/content_top 各7行,content_bottom 溢出12行),字体/spec_lock 只是 WARN 不 fail。关键发现:`executor-base.md §180-185` 早有「HARD—checker-enforced」的 snap 纪律却仍被漂移,加 prose 无用,治本须机械化。**A**:spec_lock_reference 新增 `## footer` 锁定片段——x/y 用 `margin_x`/`footer_y` 预填好、页码 `text-anchor="end"``canvas_wmargin_x`、留 `{PAGE}` 占位,executor 每页**逐字粘贴只换页码**,把 footer_y 这个跨页漂移第一大头从「每页手算」收敛成「一次编写」(executor-base 加对应硬规则,SKILL.md 同步);冒烟验证锁定片段过质检 errors=0、无 footer_y/margin_x 报错。**C**:`svg_quality_checker._ALIGN_TOL` 2→3px(2px 对手写实测过严),同步刷 SKILL/spec_lock_reference/executor-base 的「215px/212px」表述与测试值(margin drift 用例 60/63/66→60/64/68,30 测试全绿)。content_bottom 溢出(装太多项,另一性质)与 grid-snap 后处理(B)本轮未做。 - **07-20 / 0.58.45 / PPT 页脚漂移治本(工具失败聚集 #1,A+C)**:`shell/exit` 排第1(近7天16条 exit-1、近24h 6条,9task/7用户),实测拆解 15/16 是**逐页手写绝对坐标偏离 `spec_lock.layout_grid` 锁定值 216px 的「想对齐没对齐」**(footer_y 近失 27行为最大头,margin_x/content_top 各7行,content_bottom 溢出12行),字体/spec_lock 只是 WARN 不 fail。关键发现:`executor-base.md §180-185` 早有「HARD—checker-enforced」的 snap 纪律却仍被漂移,加 prose 无用,治本须机械化。**A**:spec_lock_reference 新增 `## footer` 锁定片段——x/y 用 `margin_x`/`footer_y` 预填好、页码 `text-anchor="end"``canvas_wmargin_x`、留 `{PAGE}` 占位,executor 每页**逐字粘贴只换页码**,把 footer_y 这个跨页漂移第一大头从「每页手算」收敛成「一次编写」(executor-base 加对应硬规则,SKILL.md 同步);冒烟验证锁定片段过质检 errors=0、无 footer_y/margin_x 报错。**C**:`svg_quality_checker._ALIGN_TOL` 2→3px(2px 对手写实测过严),同步刷 SKILL/spec_lock_reference/executor-base 的「215px/212px」表述与测试值(margin drift 用例 60/63/66→60/64/68,30 测试全绿)。content_bottom 溢出(装太多项,另一性质)与 grid-snap 后处理(B)本轮未做。

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。 # 改版本只动这一行。
__version__ = "0.58.47" __version__ = "0.58.48"

View File

@ -0,0 +1,134 @@
"""定层 glm.pro52 空响应(失败面板 kind=empty_response 主角,11/15/近7天)。
背景:assistant 轮既无 tool_calls 又无正文, run loop 当正常收尾静默 done
opus48 tool_use 漏成正文(narrated, diag_narrated_toolcall_2a1bc25d)
是不同现象要判 glm 到底是①真吐空(tc=0 content=0)漏成正文(narrated)
还是③正常(能回结构化 tool_calls,线上那次只是瞬态)
做法:build_agent(resume=True) 复原真实 system prompt + 全套工具 schema + **完整多轮
上下文**(空响应发生在深层,单条 user 消息复现不了 这是与 2a1bc25d 探针的关键差别),
直接打 glm.pro52,stream / non-stream 各打一发只读 DB,不跑 loop 不写库
输出 ASCII(Windows GBK 安全),完整正文写文件
用法(生产机跑,本机 import litellm ~20min):
.venv/Scripts/python.exe scripts/diag_glm_empty_probe.py [task_id] [user_id] [model_profile]
默认 = 35744bea(近7天 empty 5 最可复现)model_profile 缺省用 task 自身档(glm.pro52),
传第三参可换档做对照( deepseek_v4.pro 看是否 glm 独有)
"""
import os
import sys
from pathlib import Path
from uuid import UUID
ROOT = Path(__file__).resolve().parent.parent
env = ROOT / ".env"
if env.exists():
for line in env.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
os.environ.setdefault("LITELLM_LOCAL_MODEL_COST_MAP", "True")
sys.path.insert(0, str(ROOT))
import litellm # noqa: E402
from core.agent_builder import build_agent # noqa: E402
# 默认锁定 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")
MODEL_OVERRIDE = sys.argv[3] if len(sys.argv) > 3 else None
_tag = (MODEL_OVERRIDE or "self").replace(".", "_").replace("/", "_")
OUT = open(ROOT / "scripts" / f"_glm_empty_probe_{TASK_ID[:8]}_{_tag}.txt", "w", encoding="utf-8")
def w(*a):
print(*a, file=OUT)
agent, session, sid, tstate, wd = build_agent(
user_id=USER_ID, session_id=TASK_ID, resume=True,
model_name=MODEL_OVERRIDE,
)
schemas = agent.executor.schemas()
caps = agent.caps
llm = agent.llm
# 复原 loop 下一轮真会送的完整消息(system + 全部历史)—— 空响应就发生在这个上下文上。
messages = list(session.messages)
sys_txt = next((m["content"] for m in messages if m.get("role") == "system"), "")
roles = [m.get("role") for m in messages]
w("[SETUP]")
w(f" task={TASK_ID} model_id={caps.model_id} profile={caps.family}.{caps.variant}")
w(f" api_base={llm.api_base} temp={caps.optimal_temperature} "
f"parallel_tools={caps.parallel_tools}")
w(f" system_prompt chars={len(sys_txt)} tools={len(schemas)}")
w(f" messages count={len(messages)} last_role={roles[-1] if roles else None}")
w(f" role tail={roles[-6:]}")
w("")
kwargs_base = dict(
model=caps.model_id,
api_base=llm.api_base,
api_key=llm.api_key,
messages=messages,
tools=schemas,
temperature=caps.optimal_temperature,
timeout=300,
)
if caps.parallel_tools:
kwargs_base["parallel_tool_calls"] = True
stdout_lines = []
def probe(tag, stream):
w(f"========== {tag} ==========")
try:
if stream:
chunks = list(litellm.completion(
**kwargs_base, stream=True,
stream_options={"include_usage": True}))
resp = litellm.stream_chunk_builder(chunks)
else:
resp = litellm.completion(**kwargs_base)
except Exception as e:
w(f"[FAIL] {type(e).__name__}: {str(e)[:300]}")
stdout_lines.append(f"{tag}: FAIL {type(e).__name__}")
return
msg = resp.choices[0].message
tc = msg.tool_calls or []
content = msg.content or ""
fr = resp.choices[0].finish_reason
u = resp.usage
w(f" finish_reason={fr}")
w(f" usage prompt={u.prompt_tokens} completion={u.completion_tokens}")
w(f" tool_calls count={len(tc)}")
for i, t in enumerate(tc):
w(f" [{i}] {t.function.name}({(t.function.arguments or '')[:200]})")
w(f" content chars={len(content)}")
w(" content:")
w(content[:2000])
w("")
# 三态判定:EMPTY(真吐空)/ NARRATED(工具意图漏成正文)/ STRUCTURED / plain-text
narrated = (not tc) and ("`" in content) and ("\n- " in content or "No result received" in content)
if tc:
verdict = "STRUCTURED tool_calls"
elif not content.strip():
verdict = "EMPTY (tc=0 content=0) <-- 复现空响应"
elif narrated:
verdict = "NARRATED-as-text"
else:
verdict = "plain-text-no-tools"
stdout_lines.append(f"{tag}: {verdict} (finish={fr}, tc={len(tc)}, content={len(content)}c)")
probe("NON-STREAM", stream=False)
probe("STREAM", stream=True)
OUT.close()
for ln in stdout_lines:
print(ln)
print(f"full -> scripts/_glm_empty_probe_{TASK_ID[:8]}_{_tag}.txt")