fix(llm,loop): glm.pro52 空响应治本——禁 thinking 免推理烧穿输出上限 + loop 区分截断(bump 0.58.49)
失败面板 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) <noreply@anthropic.com>
This commit is contained in:
parent
9646f571f3
commit
f535dbaa9a
|
|
@ -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(加 glm.pro52 空响应定层探针,交生产跑,bump 0.58.48)
|
最后更新:2026-07-21(glm.pro52 空响应定层+治本:禁 thinking 免推理烧穿输出上限 + loop 区分截断,bump 0.58.49)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
|
|
||||||
### 2026-07
|
### 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.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/` 拷贝,**需重建沙箱镜像**才生效。
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
# 智谱 GLM 模型档案
|
# 智谱 GLM 模型档案
|
||||||
# 走 litellm 原生 zai provider(1.83+ 内置)。litellm 默认 api_base 是国际站 api.z.ai;
|
# 走 litellm 原生 zai provider(1.83+ 内置)。litellm 默认 api_base 是国际站 api.z.ai;
|
||||||
# 国内站 bigmodel.cn 通过 yaml 的 api_base 字段覆盖。两站 API key 不通用,env 也分开命名。
|
# 国内站 bigmodel.cn 通过 yaml 的 api_base 字段覆盖。两站 API key 不通用,env 也分开命名。
|
||||||
# thinking 暂不接:GLM 的 thinking 协议是 body `{"type":"enabled"}`,与 OpenAI/DeepSeek
|
# thinking 已接(core/llm.py _build_kwargs 的 family=="glm" 分支):GLM 协议是 body
|
||||||
# 的 `reasoning_effort` 等级不同,要 core/llm.py 加 family 分支才能透传 — 留 TODO。
|
# `{"thinking":{"type":"enabled|disabled"}}`(与 OpenAI/DeepSeek 的 `reasoning_effort`
|
||||||
|
# 等级不同族),走 extra_body 透传,由本档 `thinking_mode` 决定开关。当前均 false=禁用 ——
|
||||||
|
# 因网关侧默认开 thinking,重任务上会把输出预算烧在 reasoning_content 上撞满 65536 上限
|
||||||
|
# 被截断→空响应(task 35744bea 案);禁用后线上探针实测 reasoning_content 归零、正文照常。
|
||||||
family: glm
|
family: glm
|
||||||
|
|
||||||
variants:
|
variants:
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||||
# 改版本只动这一行。
|
# 改版本只动这一行。
|
||||||
__version__ = "0.58.48"
|
__version__ = "0.58.49"
|
||||||
|
|
|
||||||
10
core/llm.py
10
core/llm.py
|
|
@ -66,6 +66,16 @@ class LLM:
|
||||||
kwargs["parallel_tool_calls"] = True
|
kwargs["parallel_tool_calls"] = True
|
||||||
if self.caps.thinking_mode and reasoning_effort:
|
if self.caps.thinking_mode and reasoning_effort:
|
||||||
kwargs["reasoning_effort"] = 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:
|
if self.caps.prompt_caching:
|
||||||
kwargs["extra_headers"] = {"anthropic-beta": "prompt-caching-2024-07-31"}
|
kwargs["extra_headers"] = {"anthropic-beta": "prompt-caching-2024-07-31"}
|
||||||
return kwargs
|
return kwargs
|
||||||
|
|
|
||||||
22
core/loop.py
22
core/loop.py
|
|
@ -338,8 +338,18 @@ def _is_empty_response(response: Any) -> bool:
|
||||||
return not content.strip()
|
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(
|
def _log_empty_response(
|
||||||
task_id: Any, user_id: Any, model_profile: str, response: Any, attempt: int,
|
task_id: Any, user_id: Any, model_profile: str, response: Any, attempt: int,
|
||||||
|
finish_reason: str = "",
|
||||||
) -> None:
|
) -> None:
|
||||||
"""空响应留痕:stdout + usage_events(kind=empty_response)双写,任何一路失败都静默。
|
"""空响应留痕:stdout + usage_events(kind=empty_response)双写,任何一路失败都静默。
|
||||||
|
|
||||||
|
|
@ -351,7 +361,7 @@ def _log_empty_response(
|
||||||
usage = _extract_usage_details(getattr(response, "usage", None))
|
usage = _extract_usage_details(getattr(response, "usage", None))
|
||||||
print(
|
print(
|
||||||
f"[empty_response] task={task_id} mp={model_profile} attempt={attempt} "
|
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,
|
flush=True,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
|
|
@ -362,6 +372,7 @@ def _log_empty_response(
|
||||||
attempt=attempt,
|
attempt=attempt,
|
||||||
tokens_in=usage["tokens_in"],
|
tokens_in=usage["tokens_in"],
|
||||||
tokens_out=usage["tokens_out"],
|
tokens_out=usage["tokens_out"],
|
||||||
|
finish_reason=finish_reason,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # DB 不可用(如单测无 DB)不影响 stdout 留痕与重试
|
pass # DB 不可用(如单测无 DB)不影响 stdout 留痕与重试
|
||||||
|
|
@ -742,14 +753,21 @@ class AgentLoop:
|
||||||
# 空响应(tc 空且正文空):provider wire 吐空,和畸形同类瞬态故障 ——
|
# 空响应(tc 空且正文空):provider wire 吐空,和畸形同类瞬态故障 ——
|
||||||
# 丢弃本轮走非流式重试(多数瞬态重发一次即好,用户无感),留痕供面板可见。
|
# 丢弃本轮走非流式重试(多数瞬态重发一次即好,用户无感),留痕供面板可见。
|
||||||
if _is_empty_response(response):
|
if _is_empty_response(response):
|
||||||
|
fr = _finish_reason(response)
|
||||||
_log_empty_response(
|
_log_empty_response(
|
||||||
self.session.task_id, self.user_id,
|
self.session.task_id, self.user_id,
|
||||||
f"{self.caps.family}.{self.caps.variant}", response, attempt + 1,
|
f"{self.caps.family}.{self.caps.variant}", response, attempt + 1,
|
||||||
|
finish_reason=fr,
|
||||||
)
|
)
|
||||||
|
# length=达输出上限被截断(我方输出预算/推理烧穿,非网关 wire 吐空)——
|
||||||
|
# 同上下文重试大概率再撞,措辞据实区分,便于用户/日志判性质(治本在
|
||||||
|
# 模型档,如 GLM 已禁 thinking 免推理烧穿;这里保证可观测 + 不误导)。
|
||||||
|
truncated = fr == "length"
|
||||||
self._emit({
|
self._emit({
|
||||||
"type": "warn",
|
"type": "warn",
|
||||||
"msg": (
|
"msg": (
|
||||||
"模型返回空响应,丢弃本轮"
|
("模型输出达上限被截断" if truncated else "模型返回空响应")
|
||||||
|
+ ",丢弃本轮"
|
||||||
f"{'重试' if use_nonstream else ',改非流式重试'}"
|
f"{'重试' if use_nonstream else ',改非流式重试'}"
|
||||||
f" ({attempt + 1}/{self._MAX_MALFORMED_ATTEMPTS})"
|
f" ({attempt + 1}/{self._MAX_MALFORMED_ATTEMPTS})"
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -233,6 +233,7 @@ def record_empty_response(
|
||||||
attempt: int,
|
attempt: int,
|
||||||
tokens_in: int = 0,
|
tokens_in: int = 0,
|
||||||
tokens_out: int = 0,
|
tokens_out: int = 0,
|
||||||
|
finish_reason: str = "",
|
||||||
) -> None:
|
) -> None:
|
||||||
"""记一次 provider 吐空(kind=empty_response):assistant 轮既无 tool_calls 又无正文。
|
"""记一次 provider 吐空(kind=empty_response):assistant 轮既无 tool_calls 又无正文。
|
||||||
|
|
||||||
|
|
@ -254,6 +255,9 @@ def record_empty_response(
|
||||||
"attempt": int(attempt),
|
"attempt": int(attempt),
|
||||||
"tokens_in": int(tokens_in),
|
"tokens_in": int(tokens_in),
|
||||||
"tokens_out": int(tokens_out),
|
"tokens_out": int(tokens_out),
|
||||||
|
# finish_reason 区分「网关 wire 吐空」(stop/其他)与「输出达上限被截断」
|
||||||
|
# (length)—— 后者是我方输出预算/推理失控,同上下文重试无效(见 loop 处理)。
|
||||||
|
"finish_reason": finish_reason or "",
|
||||||
},
|
},
|
||||||
cost_cny=Decimal("0"),
|
cost_cny=Decimal("0"),
|
||||||
))
|
))
|
||||||
|
|
|
||||||
|
|
@ -32,8 +32,17 @@ os.environ.setdefault("LITELLM_LOCAL_MODEL_COST_MAP", "True")
|
||||||
|
|
||||||
sys.path.insert(0, str(ROOT))
|
sys.path.insert(0, str(ROOT))
|
||||||
import litellm # noqa: E402
|
import litellm # noqa: E402
|
||||||
|
import core.agent_builder as _ab # noqa: E402
|
||||||
from core.agent_builder import build_agent # 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 覆盖。
|
# 默认锁定 35744bea(近7天 empty 5 次、最可复现);可 argv 覆盖。
|
||||||
TASK_ID = sys.argv[1] if len(sys.argv) > 1 else "35744bea-49c0-41c8-86be-8d0c5c05bd32"
|
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")
|
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:
|
if caps.parallel_tools:
|
||||||
kwargs_base["parallel_tool_calls"] = True
|
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 = []
|
stdout_lines = []
|
||||||
|
|
||||||
|
|
@ -103,7 +117,9 @@ def probe(tag, stream):
|
||||||
content = msg.content or ""
|
content = msg.content or ""
|
||||||
fr = resp.choices[0].finish_reason
|
fr = resp.choices[0].finish_reason
|
||||||
u = resp.usage
|
u = resp.usage
|
||||||
|
rc = getattr(msg, "reasoning_content", None) or ""
|
||||||
w(f" finish_reason={fr}")
|
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" usage prompt={u.prompt_tokens} completion={u.completion_tokens}")
|
||||||
w(f" tool_calls count={len(tc)}")
|
w(f" tool_calls count={len(tc)}")
|
||||||
for i, t in enumerate(tc):
|
for i, t in enumerate(tc):
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,12 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
from core.loop import AgentLoop, _is_empty_response # noqa: E402
|
from core.loop import AgentLoop, _is_empty_response # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
def _resp_empty(content=None):
|
def _resp_empty(content=None, finish_reason=None):
|
||||||
"""空响应桩:无 tool_calls,content 为空/None/空白。"""
|
"""空响应桩:无 tool_calls,content 为空/None/空白。finish_reason 可选
|
||||||
|
(length=达输出上限被截断,与 wire 吐空区分)。"""
|
||||||
msg = SimpleNamespace(tool_calls=[], content=content)
|
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):
|
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.caps = SimpleNamespace(reliable_context=64_000, family="test", variant="t")
|
||||||
loop.session = SimpleNamespace(messages=[], task_id="test-task")
|
loop.session = SimpleNamespace(messages=[], task_id="test-task")
|
||||||
loop.user_id = "test-user" # 无 DB:_log_empty_response 落库路径静默跳过
|
loop.user_id = "test-user" # 无 DB:_log_empty_response 落库路径静默跳过
|
||||||
|
loop.executor = SimpleNamespace(schemas=lambda: []) # required_by_tool 取值用,空即可
|
||||||
loop.events = []
|
loop.events = []
|
||||||
loop._emit = loop.events.append
|
loop._emit = loop.events.append
|
||||||
calls = {"stream": 0, "nonstream": 0}
|
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"]
|
warns = [e for e in loop.events if e.get("type") == "warn"]
|
||||||
self.assertEqual(len(warns), AgentLoop._MAX_MALFORMED_ATTEMPTS)
|
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):
|
def test_clean_text_no_retry(self):
|
||||||
"""正常文本收尾轮不触发重试。"""
|
"""正常文本收尾轮不触发重试。"""
|
||||||
loop, calls = _make_loop([_resp_text("done")], [])
|
loop, calls = _make_loop([_resp_text("done")], [])
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue