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:
caoqianming 2026-07-21 08:16:54 +08:00
parent 9646f571f3
commit f535dbaa9a
8 changed files with 75 additions and 9 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(加 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/` 拷贝,**需重建沙箱镜像**才生效。

View File

@ -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:

View File

@ -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"

View File

@ -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

View File

@ -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})"
), ),

View File

@ -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"),
)) ))

View File

@ -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):

View File

@ -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")], [])