feat(ops): 畸形 tool_call 进工具失败聚集 + 巡检邮件只发活跃聚集(bump 0.58.19)

畸形轮整轮丢弃、messages 无痕,admin「工具失败聚集」原本看不见这类失败
(toolfail 只扫 role=tool 报错);且巡检邮件去重集是内存态,每次部署清零,
高频部署期每次重启都把 7 天窗口内早已安静的存量聚集重发一遍。

- 新增 record_malformed_tool_call:丢弃畸形轮时写 usage_events 一行
  kind=tool_malformed(units={tool,len,err,head,tail,tokens_in/out}),
  cost_cny 恒 0 —— cost 全 kind 合计且随任务展示,provider 抖动不算用户
  花销;真实 token 快照进 units 供反推浪费。DB 写失败静默不阻塞重试
- core/toolfail.py 加第二段扫描(usage_events kind=tool_malformed),聚成
  kind=malformed 同构 cluster,admin 面板/巡检邮件零改动自动带上;聚合
  逻辑抽 _add 闭包双数据源复用
- 巡检邮件发信前加 count_24h>0 过滤:已安静的聚集重启后不再重发,还在
  烧的重启后再提醒一次(刻意保留)
- 新增 tests/test_toolfail_malformed.py 4 用例(mock session_scope);
  RUN.md env 巡检段 + 故障兜底同步;PROGRESS 更新

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-07-13 12:04:22 +08:00
parent 3c714b34c4
commit da289b2ce2
10 changed files with 320 additions and 36 deletions

View File

@ -23,6 +23,7 @@
### 2026-07 ### 2026-07
- **07-13 / 0.58.19**:**畸形 tool_call 进 admin「工具失败聚集」+ 巡检邮件只发活跃聚集**(0.58.18 的观测盲区收尾 + 用户报巡检邮件太频):① 畸形留痕落库:`_log_malformed_args` 在 stdout 外加写 `usage_events` 一行 `kind=tool_malformed`(新 `record_malformed_tool_call`,units={tool,len,err,head,tail,tokens_in/out}),**cost_cny 恒 0**——cost 统计全 kind 合计且随任务展示,provider 抖动的浪费不算用户花销;真实 token 快照进 units 供反推浪费(token 汇总只算 kind=chat,不污染)。DB 写失败静默,绝不阻塞重试主路径。② `core/toolfail.py` 加第二段扫描(usage_events kind=tool_malformed),聚成 kind=malformed 的同构 cluster(signature=归一化 JSON 报错——行列号数字被 `_normalize` 抹平自动聚拢,sample=head…tail),admin 面板/巡检邮件零改动自动带上;聚合逻辑抽 `_add` 闭包双数据源复用。③ 巡检邮件降频:签名去重集是内存态、每次部署清零,高频部署期每次重启把 7 天窗口内早已安静的存量聚集重发一遍——发信前加 `count_24h>0` 过滤,已安静的不再重发,还在烧的重启后再提醒一次(刻意保留)。新增 `tests/test_toolfail_malformed.py` 4 用例(mock session_scope 不碰 DB:聚集/签名归一/双源并存/历史分桶);RUN.md env 巡检段 + 故障兜底 909 行同步。
- **07-13 / 0.58.18**:**畸形 tool_call 首败即降级非流式 + 损坏原文留痕**(用户报 task 716ed3be 芜湖零碳园区方案逐章 write 反复「工具调用参数损坏」1/3→3/3):DB 实测 deepseek-v4-pro 也踩 v4-flash 老病(流式 delta 错位粘坏大参数 arguments),且**同轮流式重 roll 失败强相关**——3~4k 字符中文 write 连挂 3 次全畸形、每章拖 3 分钟+,最后总靠非流式兜底救场,3 次流式重试纯烧 token(每次一整段 2k+ token 生成,不记账但 provider 照计费)。改法(`core/loop.py`):① `_MAX_MALFORMED_RETRIES=3`(3 流式+1 非流式)→ `_MAX_MALFORMED_ATTEMPTS=3`(1 流式+2 非流式),首败即降级;顺手修掉旧版 warn 计数可显示 "4/3" 的 off-by-one。② 新增 `_log_malformed_args`:丢弃前把损坏 arguments 首尾各 300 字符(ascii() 转义防 GBK 控制台崩)+ JSON 报错位置打 stdout `[malformed]` 标签——畸形轮不入库,这是唯一留痕,供定性 provider delta 错位 vs 本地拼接 bug 及报 case 取证(toolfail 扫描器看不见此类失败:无 tool 消息)。新增 `tests/test_loop_malformed_retry.py` 4 用例(object.__new__ 绕构造器打桩双通道):干净流式不重试/首败即非流式/耗尽返最后 response 交 executor 返错/中途 cancel 短路。RUN.md 故障兜底 909 行同步(v4-pro 也中招 + `[malformed]` 日志取证口径)。 - **07-13 / 0.58.18**:**畸形 tool_call 首败即降级非流式 + 损坏原文留痕**(用户报 task 716ed3be 芜湖零碳园区方案逐章 write 反复「工具调用参数损坏」1/3→3/3):DB 实测 deepseek-v4-pro 也踩 v4-flash 老病(流式 delta 错位粘坏大参数 arguments),且**同轮流式重 roll 失败强相关**——3~4k 字符中文 write 连挂 3 次全畸形、每章拖 3 分钟+,最后总靠非流式兜底救场,3 次流式重试纯烧 token(每次一整段 2k+ token 生成,不记账但 provider 照计费)。改法(`core/loop.py`):① `_MAX_MALFORMED_RETRIES=3`(3 流式+1 非流式)→ `_MAX_MALFORMED_ATTEMPTS=3`(1 流式+2 非流式),首败即降级;顺手修掉旧版 warn 计数可显示 "4/3" 的 off-by-one。② 新增 `_log_malformed_args`:丢弃前把损坏 arguments 首尾各 300 字符(ascii() 转义防 GBK 控制台崩)+ JSON 报错位置打 stdout `[malformed]` 标签——畸形轮不入库,这是唯一留痕,供定性 provider delta 错位 vs 本地拼接 bug 及报 case 取证(toolfail 扫描器看不见此类失败:无 tool 消息)。新增 `tests/test_loop_malformed_retry.py` 4 用例(object.__new__ 绕构造器打桩双通道):干净流式不重试/首败即非流式/耗尽返最后 response 交 executor 返错/中途 cancel 短路。RUN.md 故障兜底 909 行同步(v4-pro 也中招 + `[malformed]` 日志取证口径)。
- **07-13 / 0.58.17**:**历史消息渲染丢图片预览/chip 两根因修复**(用户报"对话里看不到预览图,有时也不挂 chip"):① 确定性回归(0.12.16 `82feece` 并发化引入):`selectTask` 的 `Promise.all``loadMessages()` 内部就调 `renderMessages()`,而 `state.taskMeta = meta` 要等 Promise.all 整体完成——首屏渲染时 taskMeta 必然是上个 task 的/null → `_workingDirName` 算出的 wd 错/空 → `extractArtifactRels``[]`,chip+inline 图整体丢失(直播 SSE 期间可见、切走再切回/刷新就"消失"的体感即此)。修法:`loadMessages` 加 `{render}` 开关,selectTask 传 `render:false` 保持并发取数,`renderMessages` 延后到 taskMeta 赋值后(零额外 RTT,只等 meta/messages 较慢者)。② 中文语境抽取正则漏挂:media.js lead 边界原要求空格/英文标点,"已保存到gpt生图/xxx.png"路径前紧贴汉字不命中;且路径后紧贴",请查看"时 tail 把中文标点连后文吞进文件名产生 404 坏 chip。修法:lead 改"行首或任意非 `[A-Za-z0-9._-]`"(汉字算边界,仍防 mygpt生图 前缀误挂),tail 排除集抽成 `_TAIL_CLS` 常量并补常见中文标点(主规则+legacy figures/videos 双处)。node 离线回归 12 用例全绿(CJK 前邻/后邻标点/前缀防护/中文文件名/legacy 不重复等)。 - **07-13 / 0.58.17**:**历史消息渲染丢图片预览/chip 两根因修复**(用户报"对话里看不到预览图,有时也不挂 chip"):① 确定性回归(0.12.16 `82feece` 并发化引入):`selectTask` 的 `Promise.all``loadMessages()` 内部就调 `renderMessages()`,而 `state.taskMeta = meta` 要等 Promise.all 整体完成——首屏渲染时 taskMeta 必然是上个 task 的/null → `_workingDirName` 算出的 wd 错/空 → `extractArtifactRels``[]`,chip+inline 图整体丢失(直播 SSE 期间可见、切走再切回/刷新就"消失"的体感即此)。修法:`loadMessages` 加 `{render}` 开关,selectTask 传 `render:false` 保持并发取数,`renderMessages` 延后到 taskMeta 赋值后(零额外 RTT,只等 meta/messages 较慢者)。② 中文语境抽取正则漏挂:media.js lead 边界原要求空格/英文标点,"已保存到gpt生图/xxx.png"路径前紧贴汉字不命中;且路径后紧贴",请查看"时 tail 把中文标点连后文吞进文件名产生 404 坏 chip。修法:lead 改"行首或任意非 `[A-Za-z0-9._-]`"(汉字算边界,仍防 mygpt生图 前缀误挂),tail 排除集抽成 `_TAIL_CLS` 常量并补常见中文标点(主规则+legacy figures/videos 双处)。node 离线回归 12 用例全绿(CJK 前邻/后邻标点/前缀防护/中文文件名/legacy 不重复等)。
- **07-13 / 0.58.16**:**有 gpt_image 权限的用户生图默认 GPT 生图**:此前 `/v1/image_models``is_default` 恒标"档位过滤后第一个"(yaml 文件名排序豆包在前 → 人人默认 seedream)。改法:`web/app.py` 新增 `_default_image_variant(allowed)`——档位集合里有 `gpt_image`(pro 档 / admin)→ 默认它,否则仍第一个(seedream);`/v1/image_models` 的 `is_default``_resolve_image_model` 的空串解析(POST messages 时客户端没显式选 image_model)共用该 helper,保证下拉默认选中项和实际起 run 挂的图像工具永远一致。用户可在顶栏下拉自行切回豆包 Seedream(选中值随消息显式发送,不受默认影响)。前端零改动(chat.js 本就选 `is_default` 项);无 user_id 的内部路径(定时任务/messenger)行为不变(空串 → agent_builder yaml 第一个兜底)。 - **07-13 / 0.58.16**:**有 gpt_image 权限的用户生图默认 GPT 生图**:此前 `/v1/image_models``is_default` 恒标"档位过滤后第一个"(yaml 文件名排序豆包在前 → 人人默认 seedream)。改法:`web/app.py` 新增 `_default_image_variant(allowed)`——档位集合里有 `gpt_image`(pro 档 / admin)→ 默认它,否则仍第一个(seedream);`/v1/image_models` 的 `is_default``_resolve_image_model` 的空串解析(POST messages 时客户端没显式选 image_model)共用该 helper,保证下拉默认选中项和实际起 run 挂的图像工具永远一致。用户可在顶栏下拉自行切回豆包 Seedream(选中值随消息显式发送,不受默认影响)。前端零改动(chat.js 本就选 `is_default` 项);无 user_id 的内部路径(定时任务/messenger)行为不变(空串 → agent_builder yaml 第一个兜底)。

9
RUN.md
View File

@ -70,8 +70,11 @@
# SMTP_FROM=you@qq.com # 可选,默认取 SMTP_USER # SMTP_FROM=you@qq.com # 可选,默认取 SMTP_USER
# SMTP_FROM_NAME=总院科研辅助智能体 # 可选,发件人显示名,默"总院科研辅助智能体"(不暴露内部代号) # SMTP_FROM_NAME=总院科研辅助智能体 # 可选,发件人显示名,默"总院科研辅助智能体"(不暴露内部代号)
# 工具失败聚集巡检(0.58.11,反 9dcae061"挂 90 天无人知"):web 进程每天扫近 7 天 # 工具失败聚集巡检(0.58.11,反 9dcae061"挂 90 天无人知"):web 进程每天扫近 7 天
# tool 错误消息,同签名 >=5 次且跨 >=2 task 判聚集 → 发下面邮箱(复用上面 SMTP_*; # tool 错误消息 + 被丢弃的畸形 tool_call 参数(0.58.19,kind=malformed),同签名 >=5 次
# 未配邮箱或 SMTP 则只打日志 [toolfail] 行)。admin 页"工具失败"表可随时看低阈值全量。 # 且跨 >=2 task 判聚集 → 发下面邮箱(复用上面 SMTP_*;未配邮箱或 SMTP 则只打日志
# [toolfail] 行)。**只发近 24h 仍活跃的聚集**(0.58.19):去重集是内存态、每次部署清零,
# 不加活跃过滤的话高频部署期每次重启都会把 7 天窗口内早已安静的存量聚集重发一遍。
# admin 页"工具失败"表可随时看低阈值全量(含已安静的灰行)。
# ZCBOT_DEVELOPER_EMAIL=dev@example.com # ZCBOT_DEVELOPER_EMAIL=dev@example.com
# ZCBOT_TOOLFAIL_SCAN_INTERVAL=86400 # 秒,默 86400;<=0 关掉巡检 # ZCBOT_TOOLFAIL_SCAN_INTERVAL=86400 # 秒,默 86400;<=0 关掉巡检
# broker 外置(DESIGN §7.0,蓝绿双实例跨进程 SSE/cancel):可选。设了 → event/cancel # broker 外置(DESIGN §7.0,蓝绿双实例跨进程 SSE/cancel):可选。设了 → event/cancel
@ -906,7 +909,7 @@ sudo xfs_quota -x -c "limit -p bhard=10g zcbot_<user_uuid>" /opt
| matplotlib / mermaid 出的 PNG 里中文全是方块(豆腐块 □) | sandbox 镜像缺中文字体。Dockerfile 已加 `fonts-noto-cjk fonts-wqy-microhei` + `fc-cache`,但**改了 Dockerfile 必须重 build 镜像 + 清旧容器**才生效(旧容器仍跑老镜像):`docker build -t zcbot-sandbox:latest -f deploy/sandbox/Dockerfile --build-arg HOST_UID=$(id -u) --build-arg HOST_GID=$(id -g) .` → `docker rm -f $(docker ps -aq -f label=zcbot.product=sandbox)``systemctl restart zcbot`。验证:`docker run --rm zcbot-sandbox:latest fc-list :lang=zh` 应列出 Noto/WenQuanYi | | matplotlib / mermaid 出的 PNG 里中文全是方块(豆腐块 □) | sandbox 镜像缺中文字体。Dockerfile 已加 `fonts-noto-cjk fonts-wqy-microhei` + `fc-cache`,但**改了 Dockerfile 必须重 build 镜像 + 清旧容器**才生效(旧容器仍跑老镜像):`docker build -t zcbot-sandbox:latest -f deploy/sandbox/Dockerfile --build-arg HOST_UID=$(id -u) --build-arg HOST_GID=$(id -g) .` → `docker rm -f $(docker ps -aq -f label=zcbot.product=sandbox)``systemctl restart zcbot`。验证:`docker run --rm zcbot-sandbox:latest fc-list :lang=zh` 应列出 Noto/WenQuanYi |
| dev.html 显示 "load failed" 立刻回登录页 | token 过期或 JWT_SECRET 服务端变了。已自动跳登录页,按上次 tab 重登 | | dev.html 显示 "load failed" 立刻回登录页 | token 过期或 JWT_SECRET 服务端变了。已自动跳登录页,按上次 tab 重登 |
| dev.html 顶栏出现"连接断开,重连中…(N/3)" | SSE 流被切(`--reload` 重启 / nginx 切换 / 网络抖)。客户端自动重连,1s/2s/4s 退避;新进程已 reaper 标 error 则立即收 done + 卡片末尾"请重发"提示;若服务端还活着会继续看后续 delta(断开期间的丢失,broker 不持久化) | | dev.html 顶栏出现"连接断开,重连中…(N/3)" | SSE 流被切(`--reload` 重启 / nginx 切换 / 网络抖)。客户端自动重连,1s/2s/4s 退避;新进程已 reaper 标 error 则立即收 done + 卡片末尾"请重发"提示;若服务端还活着会继续看后续 delta(断开期间的丢失,broker 不持久化) |
| 对话里偶发 `[Error] invalid JSON arguments` / warn「工具调用参数损坏…重试」 | deepseek v4 系(flash/**pro 也踩过**,2026-07 task 716ed3be)**大参数工具调用(大 write/run_python,≈3K+ 字符中文长文)偶发把内容碎片错位粘进 arguments 或退化成空 `{}`**(上游流式抖动,同轮流式重 roll 连挂强相关)。`core/loop.py` 已自动兜底:畸形参数丢弃整轮,**首败即降级非流式重试**(≤3 次总尝试),非流式历史上从未再畸形。每次丢弃会服务端日志 `[malformed] task=... tool=... head=... tail=...`(损坏原文唯一留痕,journalctl 里 grep `\[malformed\]` 取证/报 provider case)。仍频繁撞 → 引导模型把大文件拆小分块写 | | 对话里偶发 `[Error] invalid JSON arguments` / warn「工具调用参数损坏…重试」 | deepseek v4 系(flash/**pro 也踩过**,2026-07 task 716ed3be)**大参数工具调用(大 write/run_python,≈3K+ 字符中文长文)偶发把内容碎片错位粘进 arguments 或退化成空 `{}`**(上游流式抖动,同轮流式重 roll 连挂强相关)。`core/loop.py` 已自动兜底:畸形参数丢弃整轮,**首败即降级非流式重试**(≤3 次总尝试),非流式历史上从未再畸形。每次丢弃会留痕两路:服务端日志 `[malformed] task=... tool=... head=... tail=...`(journalctl 里 grep `\[malformed\]` 取证/报 provider case)+ `usage_events` 一行 `kind=tool_malformed`(cost=0,admin 页「工具失败」表以 kind=malformed 聚集展示,可按模型分组看哪个 provider 在坏)。仍频繁撞 → 引导模型把大文件拆小分块写 |
| 蓝绿脚本报 `旧单实例服务 zcbot.service 还在跑` | 还没做蓝绿 bootstrap(旧服务占 8765 与 `zcbot@blue` 冲突)。走 RUN.md「蓝绿部署」bootstrap 段:`disable --now zcbot` 后起 `zcbot@blue` | | 蓝绿脚本报 `旧单实例服务 zcbot.service 还在跑` | 还没做蓝绿 bootstrap(旧服务占 8765 与 `zcbot@blue` 冲突)。走 RUN.md「蓝绿部署」bootstrap 段:`disable --now zcbot` 后起 `zcbot@blue` |
| 蓝绿 wait_health 报 `instance 不是 blue/green` | 实例 env 没生效:`/etc/systemd/system/zcbot@.service` 未装 / 没 `daemon-reload` / `deploy/instances/<色>.env` 缺失。`systemctl cat zcbot@blue` 核对 `EnvironmentFile` 两行都在 | | 蓝绿 wait_health 报 `instance 不是 blue/green` | 实例 env 没生效:`/etc/systemd/system/zcbot@.service` 未装 / 没 `daemon-reload` / `deploy/instances/<色>.env` 缺失。`systemctl cat zcbot@blue` 核对 `EnvironmentFile` 两行都在 |
| 蓝绿切换后用户直播不动、但任务其实在跑 | 用户恰在切换窗口刷新页面,run 还在旧实例上而 SSE 连到了新实例(broker 进程内不跨实例)。run 照常跑完落库,等旧实例日志安静后让用户刷新看历史;非 bug,已知边缘 | | 蓝绿切换后用户直播不动、但任务其实在跑 | 用户恰在切换窗口刷新页面,run 还在旧实例上而 SSE 连到了新实例(broker 进程内不跨实例)。run 照常跑完落库,等旧实例日志安静后让用户刷新看历史;非 bug,已知边缘 |

View File

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

View File

@ -11,6 +11,7 @@ from __future__ import annotations
import hashlib import hashlib
import json import json
import threading
import time import time
from pathlib import Path from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple from typing import Any, Callable, Dict, List, Optional, Tuple
@ -25,7 +26,7 @@ from .context_fold import maybe_fold
from .executor import ExecCtx, Executor from .executor import ExecCtx, Executor
from .llm import LLM from .llm import LLM
from .session import Session from .session import Session
from .storage import record_chat_usage from .storage import record_chat_usage, record_malformed_tool_call
from . import pptx_guard from . import pptx_guard
# 产物机检只挂能落盘的执行类工具(fs 写工具不适合造 pptx,机检无意义) # 产物机检只挂能落盘的执行类工具(fs 写工具不适合造 pptx,机检无意义)
@ -171,17 +172,21 @@ def _malformed_tool_calls(response: Any) -> List[str]:
return bad return bad
def _log_malformed_args(task_id: Any, response: Any) -> None: def _log_malformed_args(
"""畸形 arguments 的首尾片段 + JSON 报错位置打服务端日志(stdout)。 task_id: Any, user_id: Any, model_profile: str, response: Any,
) -> None:
"""畸形 arguments 的首尾片段 + JSON 报错位置留痕:stdout + usage_events 双写。
畸形轮不 append/不记账,这里是唯一留痕 用于事后定性损坏形态(provider 流式 畸形轮不 append/不记账,这里是唯一留痕 用于事后定性损坏形态(provider 流式
delta 错位 vs 本地 stream_chunk_builder 拼接 bug),以及向 provider case 取证 delta 错位 vs 本地 stream_chunk_builder 拼接 bug),以及向 provider case 取证
片段过 ascii() 转义:日志消费端编码不可控(Windows dev 控制台 GBK emoji 会崩), stdout 片段过 ascii() 转义:日志消费端编码不可控(Windows dev 控制台 GBK emoji
转义后 grep '\\[malformed\\]' 拿到的内容可无损还原日志失败静默 留痕绝不能 会崩),转义后 grep '\\[malformed\\]' 拿到的内容可无损还原DB (kind=tool_malformed,
反过来打断重试主路径 cost=0) admin工具失败聚集面板 + 巡检邮件(core/toolfail.py),units
快照该轮真实 token 供估算浪费任何一路失败都静默 留痕绝不能反过来打断重试主路径
""" """
try: try:
msg = response.choices[0].message msg = response.choices[0].message
usage = _extract_usage_details(getattr(response, "usage", None))
for tc in (getattr(msg, "tool_calls", None) or []): for tc in (getattr(msg, "tool_calls", None) or []):
raw = (getattr(tc.function, "arguments", None) or "").strip() raw = (getattr(tc.function, "arguments", None) or "").strip()
if not raw: if not raw:
@ -195,6 +200,21 @@ def _log_malformed_args(task_id: Any, response: Any) -> None:
f"head={ascii(raw[:300])} tail={ascii(raw[-300:])}", f"head={ascii(raw[:300])} tail={ascii(raw[-300:])}",
flush=True, flush=True,
) )
try:
record_malformed_tool_call(
task_id=task_id,
user_id=user_id,
model_profile=model_profile,
tool=tc.function.name,
arg_len=len(raw),
error=str(e),
head=raw[:300],
tail=raw[-300:],
tokens_in=usage["tokens_in"],
tokens_out=usage["tokens_out"],
)
except Exception:
pass # DB 不可用(如单测无 DB)不影响 stdout 留痕与重试
except Exception: except Exception:
pass pass
@ -463,7 +483,8 @@ class AgentLoop:
返回 (response, cancelled_mid_stream): 返回 (response, cancelled_mid_stream):
- 正常完结 (response, False);response shape 与非流式 completion() 等价 - 正常完结 (response, False);response shape 与非流式 completion() 等价
(choices[0].message + usage) (choices[0].message + usage)
- 中途 cancel (None, True);已收 chunk 丢弃,内层 generator finally 关闭底层连接 - 中途 cancel (None, True);已收 chunk 丢弃,内层 generator finally 关闭底层连接
非流式重试期间 cancel 同样 (None, True)(线程级 poll, _nonstream_once)
畸形重试:deepseek v4 (flash/pro 均实测踩过)大参数工具调用偶发把内容碎片 畸形重试:deepseek v4 (flash/pro 均实测踩过)大参数工具调用偶发把内容碎片
错位粘进 arguments,拼回后 JSON 解析失败这种损坏一旦入库会被每轮重发诱导 错位粘进 arguments,拼回后 JSON 解析失败这种损坏一旦入库会被每轮重发诱导
@ -480,17 +501,24 @@ class AgentLoop:
self.session.messages, self.session.messages,
compact_threshold_chars=compact_threshold, compact_threshold_chars=compact_threshold,
) )
self._emit({ llm_start_event = {
"type": "llm_start", "type": "llm_start",
**{f"context_{k}": v for k, v in context_stats.items()}, **{f"context_{k}": v for k, v in context_stats.items()},
# 窗口总容量(chars 口径,= reliable_context×2.5):前端压缩指示环算占用比; # 窗口总容量(chars 口径,= reliable_context×2.5):前端压缩指示环算占用比;
# 50% 进压缩区、85% 触发折叠(context_fold)。 # 50% 进压缩区、85% 触发折叠(context_fold)。
"context_limit_chars": int(self.caps.reliable_context * self._CHARS_PER_TOKEN), "context_limit_chars": int(self.caps.reliable_context * self._CHARS_PER_TOKEN),
}) }
for attempt in range(self._MAX_MALFORMED_ATTEMPTS): for attempt in range(self._MAX_MALFORMED_ATTEMPTS):
use_nonstream = attempt > 0 use_nonstream = attempt > 0
# 每个 attempt 重发 llm_start(stats 同一份):非流式重试完成前零 delta 事件,
# 而 warn 事件会让前端把当前文字段定稿关闭 —— 不重发的话「思考中 · Ns」占位段
# 没人重建,页面静止到重试完成,与卡死无法区分。
self._emit(dict(llm_start_event))
if use_nonstream: if use_nonstream:
response = self._nonstream_once(llm_messages) response = self._nonstream_once(llm_messages)
if response is None:
# 非流式重试期间用户点了停止(线程级 poll,见 _nonstream_once)
return None, True
else: else:
response, cancelled = self._collect_stream_once(llm_messages) response, cancelled = self._collect_stream_once(llm_messages)
if cancelled: if cancelled:
@ -499,7 +527,10 @@ class AgentLoop:
bad = _malformed_tool_calls(response) bad = _malformed_tool_calls(response)
if not bad: if not bad:
return response, False return response, False
_log_malformed_args(self.session.task_id, response) _log_malformed_args(
self.session.task_id, self.user_id,
f"{self.caps.family}.{self.caps.variant}", response,
)
self._emit({ self._emit({
"type": "warn", "type": "warn",
"msg": ( "msg": (
@ -552,14 +583,39 @@ class AgentLoop:
response = litellm.stream_chunk_builder(chunks, messages=llm_messages) response = litellm.stream_chunk_builder(chunks, messages=llm_messages)
return response, False return response, False
def _nonstream_once(self, llm_messages: List[dict]) -> Any: _NONSTREAM_CANCEL_POLL_SECS = 0.5
def _nonstream_once(self, llm_messages: List[dict]) -> Optional[Any]:
"""非流式兜底:provider 服务端一次性拼好 tool_calls,绕开流式 delta 错位。 """非流式兜底:provider 服务端一次性拼好 tool_calls,绕开流式 delta 错位。
没有 chunk cancel,content 也拿不到 delta 整段 text 一次性补 emit""" 没有 chunk cancel,content 也拿不到 delta 整段 text 一次性补 emit
response = self.llm.chat(
cancel 响应:非流式一次生成可达分钟级(重试轮常是大参数 write),同步阻塞会让
停止按钮等到调用整个返回才生效故调用放后台 daemon 线程,主线程按拍 poll
cancel:命中即返回 None(线程弃养自行跑完,响应到达后被丢弃,不入库不记账
与流式 cancel 丢弃已收 chunk 的语义一致)"""
box: Dict[str, Any] = {}
done = threading.Event()
def _call() -> None:
try:
box["resp"] = self.llm.chat(
messages=llm_messages, messages=llm_messages,
tools=self.executor.schemas(), tools=self.executor.schemas(),
reasoning_effort=self.caps.default_reasoning_effort or None, reasoning_effort=self.caps.default_reasoning_effort or None,
) )
except BaseException as e: # noqa: BLE001 — 原样转抛回主线程
box["exc"] = e
finally:
done.set()
worker = threading.Thread(target=_call, daemon=True, name="llm-nonstream-retry")
worker.start()
while not done.wait(self._NONSTREAM_CANCEL_POLL_SECS):
if self._is_cancelled():
return None
if "exc" in box:
raise box["exc"]
response = box["resp"]
try: try:
msg = response.choices[0].message msg = response.choices[0].message
rc = getattr(msg, "reasoning_content", None) rc = getattr(msg, "reasoning_content", None)

View File

@ -12,7 +12,7 @@ from .engine import (
get_engine, get_engine,
session_scope, session_scope,
) )
from .usage import record_chat_usage from .usage import record_chat_usage, record_malformed_tool_call
from .utils import ( from .utils import (
NoSubtaskError, NoSubtaskError,
check_no_subtask, check_no_subtask,
@ -29,6 +29,7 @@ __all__ = [
"get_engine", "get_engine",
"get_task", "get_task",
"record_chat_usage", "record_chat_usage",
"record_malformed_tool_call",
"session_scope", "session_scope",
"update_task", "update_task",
"upsert_task", "upsert_task",

View File

@ -149,6 +149,47 @@ def record_chat_usage(
return cost_cny return cost_cny
def record_malformed_tool_call(
*,
task_id: UUID,
user_id: UUID,
model_profile: str,
tool: str,
arg_len: int,
error: str,
head: str,
tail: str,
tokens_in: int = 0,
tokens_out: int = 0,
) -> None:
"""记一次被丢弃的畸形 tool_call(kind=tool_malformed)。
这不是记账而是留痕:畸形轮整轮丢弃messages 无痕,此行是 admin工具失败聚集
面板与巡检邮件唯一的数据源(core/toolfail.py 第二段扫描按 units->>'tool'/'err'
聚合)cost_cny 0 cost 统计全 kind 合计且随任务展示,provider 抖动的浪费
不该算成用户花销;该轮真实 token 快照进 units,将来要算浪费成本可从 units 反推
(token 汇总只算 kind=chat,此处 tokens_in/out 不会混进 token 统计)
"""
with session_scope() as s:
s.add(UsageEvent(
user_id=user_id,
task_id=task_id,
message_id=None, # 畸形轮不入 messages,无可关联
kind="tool_malformed",
model_profile=model_profile,
units={
"tool": tool,
"len": int(arg_len),
"err": str(error)[:200],
"head": head,
"tail": tail,
"tokens_in": int(tokens_in),
"tokens_out": int(tokens_out),
},
cost_cny=Decimal("0"),
))
def record_image_usage( def record_image_usage(
*, *,
task_id: UUID, task_id: UUID,

View File

@ -8,6 +8,10 @@
纯只读查询无新表无状态;告警通道由调用方决定(web/app.py 的巡检 loop 纯只读查询无新表无状态;告警通道由调用方决定(web/app.py 的巡检 loop
开发者邮箱,admin API 直接返给前端表格) 开发者邮箱,admin API 直接返给前端表格)
第二数据源(0.58.19):被丢弃的畸形 tool_call 参数(kind=malformed)这类失败
整轮不入 messages(防投毒级联),loop usage_events(kind=tool_malformed),
在此并入同一聚合口径(signature=归一化 JSON 报错,sample=损坏参数首尾片段)
失败判定(tool content 的三类标记,形态见 executor_docker/_host): 失败判定(tool content 的三类标记,形态见 executor_docker/_host):
- `[Error` 开头 执行器/工具层报错([Error][Error executing ...]) - `[Error` 开头 执行器/工具层报错([Error][Error executing ...])
- `command timed out` shell/run_python 超时 - `command timed out` shell/run_python 超时
@ -85,7 +89,6 @@ def scan_tool_failures(
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
cutoff = now - timedelta(days=days) cutoff = now - timedelta(days=days)
n_buckets = max(1, math.ceil(days)) n_buckets = max(1, math.ceil(days))
rows = []
with session_scope() as s: with session_scope() as s:
rows = s.execute( rows = s.execute(
text( text(
@ -101,15 +104,28 @@ def scan_tool_failures(
), ),
{"cutoff": cutoff}, {"cutoff": cutoff},
).fetchall() ).fetchall()
# 第二段:被丢弃的畸形 tool_call 参数(kind=tool_malformed)。这类失败整轮
# 不入 messages(防投毒),loop._log_malformed_args 落在 usage_events,
# 是它们进面板/巡检邮件的唯一路径。
mrows = s.execute(
text(
"select task_id, user_id, created_at, "
" units->>'tool' as tool_name, "
" units->>'err' as err, "
" units->>'head' as head, "
" units->>'tail' as tail "
"from usage_events "
"where kind = 'tool_malformed' and created_at >= :cutoff"
),
{"cutoff": cutoff},
).fetchall()
agg: Dict[Tuple[str, str], Dict[str, Any]] = {} agg: Dict[Tuple[str, str], Dict[str, Any]] = {}
for task_id, user_id, created_at, tool_name, content in rows:
if not content: def _add(
continue tool_name: str, kind: str, sig_line: str, sample: str,
hit = _classify(content) task_id: Any, user_id: Any, created_at: datetime,
if hit is None: ) -> None:
continue
kind, sig_line = hit
# DB 列若是 naive timestamp(存 UTC),补 tzinfo 才能和 now 做减法 # DB 列若是 naive timestamp(存 UTC),补 tzinfo 才能和 now 做减法
ts = created_at if created_at.tzinfo else created_at.replace(tzinfo=timezone.utc) ts = created_at if created_at.tzinfo else created_at.replace(tzinfo=timezone.utc)
key = (tool_name or "?", _normalize(sig_line)) key = (tool_name or "?", _normalize(sig_line))
@ -124,7 +140,7 @@ def scan_tool_failures(
"users": set(), "users": set(),
"first_at": ts, "first_at": ts,
"last_at": ts, "last_at": ts,
"sample": content[:300], "sample": sample[:300],
"daily": [0] * n_buckets, "daily": [0] * n_buckets,
} }
c["count"] += 1 c["count"] += 1
@ -137,7 +153,23 @@ def scan_tool_failures(
c["first_at"] = ts c["first_at"] = ts
if ts > c["last_at"]: if ts > c["last_at"]:
c["last_at"] = ts c["last_at"] = ts
c["sample"] = content[:300] c["sample"] = sample[:300]
for task_id, user_id, created_at, tool_name, content in rows:
if not content:
continue
hit = _classify(content)
if hit is None:
continue
kind, sig_line = hit
_add(tool_name, kind, sig_line, content, task_id, user_id, created_at)
for task_id, user_id, created_at, tool_name, err, head, tail in mrows:
_add(
tool_name, "malformed", err or "?",
f"{head or ''}{tail or ''}",
task_id, user_id, created_at,
)
out = [] out = []
for c in agg.values(): for c in agg.values():

View File

@ -1,6 +1,8 @@
from __future__ import annotations from __future__ import annotations
import sys import sys
import threading
import time
import unittest import unittest
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
@ -24,8 +26,9 @@ BAD = '].cells[1].merge({"path": "a.md"' # 流式 delta 错位的典型形态
def _make_loop(stream_results, nonstream_results): def _make_loop(stream_results, nonstream_results):
"""绕开构造器搭 AgentLoop:只装 _stream_llm 用到的属性,打桩两条取流路径。""" """绕开构造器搭 AgentLoop:只装 _stream_llm 用到的属性,打桩两条取流路径。"""
loop = object.__new__(AgentLoop) loop = object.__new__(AgentLoop)
loop.caps = SimpleNamespace(reliable_context=64_000) 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_malformed_args 的落库路径应静默跳过
loop.events = [] loop.events = []
loop._emit = loop.events.append loop._emit = loop.events.append
calls = {"stream": 0, "nonstream": 0} calls = {"stream": 0, "nonstream": 0}
@ -61,6 +64,9 @@ class TestMalformedRetry(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), 1) self.assertEqual(len(warns), 1)
self.assertIn("非流式", warns[0]["msg"]) self.assertIn("非流式", warns[0]["msg"])
# 每个 attempt 重发 llm_start:warn 关掉前端占位段后,靠它重建「思考中」跳秒
starts = [e for e in loop.events if e.get("type") == "llm_start"]
self.assertEqual(len(starts), 2)
def test_all_attempts_exhausted_returns_last(self): def test_all_attempts_exhausted_returns_last(self):
"""全部尝试耗尽仍畸形 → 返回最后一次 response(交 executor 返错),不死循环。""" """全部尝试耗尽仍畸形 → 返回最后一次 response(交 executor 返错),不死循环。"""
@ -81,6 +87,52 @@ class TestMalformedRetry(unittest.TestCase):
self.assertIsNone(resp) self.assertIsNone(resp)
self.assertEqual(calls["nonstream"], 0) self.assertEqual(calls["nonstream"], 0)
def test_cancel_during_nonstream_retry(self):
"""非流式重试期间 cancel(_nonstream_once 返回 None)→ (None, True),不再续试。"""
loop, calls = _make_loop([_resp(BAD)], [None, _resp(GOOD)])
resp, cancelled = loop._stream_llm()
self.assertTrue(cancelled)
self.assertIsNone(resp)
self.assertEqual(calls, {"stream": 1, "nonstream": 1})
def test_nonstream_once_polls_cancel(self):
"""真 _nonstream_once:llm.chat 阻塞时点停止,应在 poll 拍内返回 None,
不等调用整个返回(此前同步阻塞,分钟级生成期间停止按钮无效)"""
loop = object.__new__(AgentLoop)
loop.caps = SimpleNamespace(default_reasoning_effort=None)
loop.executor = SimpleNamespace(schemas=lambda: [])
loop.cancel_check = lambda: True
block = threading.Event()
def slow_chat(**kwargs):
block.wait(30)
return _resp(GOOD)
loop.llm = SimpleNamespace(chat=slow_chat)
t0 = time.monotonic()
resp = loop._nonstream_once([])
elapsed = time.monotonic() - t0
block.set() # 释放弃养线程,别让它拖住测试进程退出
self.assertIsNone(resp)
self.assertLess(elapsed, 5)
def test_nonstream_once_returns_response_when_not_cancelled(self):
"""不取消时行为与原同步版等价:拿到 response 并补 emit 整段 text。"""
loop = object.__new__(AgentLoop)
loop.caps = SimpleNamespace(default_reasoning_effort=None)
loop.executor = SimpleNamespace(schemas=lambda: [])
loop.cancel_check = None
loop.events = []
loop._emit = loop.events.append
good = _resp(GOOD)
good.choices[0].message.content = "hello"
loop.llm = SimpleNamespace(chat=lambda **kwargs: good)
resp = loop._nonstream_once([])
self.assertIs(resp, good)
texts = [e for e in loop.events if e.get("type") == "text"]
self.assertEqual(len(texts), 1)
self.assertEqual(texts[0]["delta"], "hello")
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@ -0,0 +1,95 @@
from __future__ import annotations
import sys
import unittest
from contextlib import contextmanager
from datetime import datetime, timedelta, timezone
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import core.toolfail as tf # noqa: E402
NOW = datetime.now(timezone.utc)
class _FakeSession:
"""按 SQL 关键字分流两段查询:messages 段与 usage_events(tool_malformed)段。"""
def __init__(self, msg_rows, malformed_rows):
self._msg_rows = msg_rows
self._malformed_rows = malformed_rows
def execute(self, clause, params=None):
sql = str(clause)
rows = self._malformed_rows if "tool_malformed" in sql else self._msg_rows
return SimpleNamespace(fetchall=lambda: rows)
def _scan(msg_rows, malformed_rows, **kw):
@contextmanager
def fake_scope():
yield _FakeSession(msg_rows, malformed_rows)
with patch.object(tf, "session_scope", fake_scope):
return tf.scan_tool_failures(**kw)
class TestToolfailMalformed(unittest.TestCase):
def test_malformed_rows_cluster(self):
"""usage_events 的 tool_malformed 行聚成 kind=malformed 的 cluster。"""
err = "Expecting value: line 1 column 1 (char 0)"
rows = [
("t1", "u1", NOW - timedelta(hours=1), "write", err, "].cells[1]", '"}'),
("t2", "u1", NOW - timedelta(hours=2), "write", err, "].merge(", '"}'),
]
out = _scan([], rows, days=7, min_count=2, min_tasks=2)
self.assertEqual(len(out), 1)
c = out[0]
self.assertEqual(c["tool"], "write")
self.assertEqual(c["kind"], "malformed")
self.assertEqual(c["count"], 2)
self.assertEqual(c["count_24h"], 2)
self.assertEqual(c["task_count"], 2)
self.assertIn("", c["sample"]) # sample = head … tail
def test_err_signature_normalized(self):
"""报错里的行号/列号数字被归一,不同位置的同类报错聚成一条。"""
rows = [
("t1", "u1", NOW, "write", "Expecting ',' delimiter: line 1 column 57 (char 56)", "h", "t"),
("t2", "u2", NOW, "write", "Expecting ',' delimiter: line 3 column 9 (char 88)", "h", "t"),
]
out = _scan([], rows, days=7, min_count=2, min_tasks=2)
self.assertEqual(len(out), 1)
def test_merges_with_message_clusters(self):
"""两段数据源并存互不干扰:messages 报错与 malformed 各出各的 cluster。"""
msg_rows = [
("t1", "u1", NOW, "glob", "[Error] base path not found"),
("t2", "u2", NOW, "glob", "[Error] base path not found"),
]
mal_rows = [
("t3", "u1", NOW, "write", "Expecting value", "h", "t"),
("t4", "u2", NOW, "write", "Expecting value", "h", "t"),
]
out = _scan(msg_rows, mal_rows, days=7, min_count=2, min_tasks=2)
kinds = sorted(c["kind"] for c in out)
self.assertEqual(kinds, ["error", "malformed"])
def test_quiet_cluster_daily_bucket(self):
"""3 天前的畸形记录:count_24h=0(已安静),落在对应历史桶。"""
rows = [
("t1", "u1", NOW - timedelta(days=3, hours=1), "write", "Expecting value", "h", "t"),
("t2", "u2", NOW - timedelta(days=3, hours=2), "write", "Expecting value", "h", "t"),
]
out = _scan([], rows, days=7, min_count=2, min_tasks=2)
c = out[0]
self.assertEqual(c["count_24h"], 0)
self.assertEqual(sum(c["daily"]), 2)
self.assertEqual(c["daily"][-4], 2) # 尾桶=近24h,往前退 3 桶
if __name__ == "__main__":
unittest.main()

View File

@ -977,8 +977,10 @@ def create_app() -> FastAPI:
# ── 工具失败聚集巡检(0.58.11)── 反 9dcae061 教训(mmdc 在生产挂 90 天 # ── 工具失败聚集巡检(0.58.11)── 反 9dcae061 教训(mmdc 在生产挂 90 天
# 0 成功无人知,靠人工扫 DB 才发现):每天扫近 7 天 tool 错误消息,同签名 # 0 成功无人知,靠人工扫 DB 才发现):每天扫近 7 天 tool 错误消息,同签名
# >=5 次且跨 >=2 task 判聚集,发 ZCBOT_DEVELOPER_EMAIL(复用 SMTP_*;未配 # >=5 次且跨 >=2 task 判聚集,发 ZCBOT_DEVELOPER_EMAIL(复用 SMTP_*;未配
# 邮箱/SMTP 则只打日志)。签名进程内去重 —— 重启/蓝绿切换后同签名会再推 # 邮箱/SMTP 则只打日志)。签名进程内去重,且只发近 24h 仍活跃的聚集
# 一次(聚集还在=问题还在,重提醒可接受)。ZCBOT_TOOLFAIL_SCAN_INTERVAL # (count_24h>0)—— 去重集是内存态,重启/蓝绿清零,高频部署期若不加活跃
# 过滤,每次部署都会把 7 天窗口内早已安静的存量聚集重发一遍(0.58.19 实改;
# 还在烧的重启后再提醒一次是刻意保留的)。ZCBOT_TOOLFAIL_SCAN_INTERVAL
# 秒,默 86400,<=0 整体关掉。 # 秒,默 86400,<=0 整体关掉。
dev_email = os.getenv("ZCBOT_DEVELOPER_EMAIL", "").strip() dev_email = os.getenv("ZCBOT_DEVELOPER_EMAIL", "").strip()
toolfail_interval = int(os.getenv("ZCBOT_TOOLFAIL_SCAN_INTERVAL", "").strip() or 86400) toolfail_interval = int(os.getenv("ZCBOT_TOOLFAIL_SCAN_INTERVAL", "").strip() or 86400)
@ -992,7 +994,8 @@ def create_app() -> FastAPI:
try: try:
clusters = await loop.run_in_executor(None, scan_tool_failures) clusters = await loop.run_in_executor(None, scan_tool_failures)
fresh = [c for c in clusters fresh = [c for c in clusters
if (c["tool"], c["signature"]) not in alerted] if c.get("count_24h")
and (c["tool"], c["signature"]) not in alerted]
if fresh: if fresh:
alerted.update((c["tool"], c["signature"]) for c in fresh) alerted.update((c["tool"], c["signature"]) for c in fresh)
for c in fresh: for c in fresh: