fix(loop): 工具调用 arguments 损坏时丢弃重试 + 非流式兜底,断投毒级联

deepseek-v4-flash 大参数工具调用(大 write/run_python,≈7K+ 字符)偶发把内容
碎片错位粘进 arguments 开头致 JSON 解析失败,或退化成空 {} 缺参。隔离批量验证
(干净上下文)非流式 8/8、流式 8/8 全干净 → 上游间歇抖动,概率低;真正放大成
灾的是 loop 把损坏的 assistant 消息入库 + 每轮重发,诱导模型继续学坏(投毒级联)。

- core/loop.py:_stream_llm 拉一轮后用 _malformed_tool_calls 校验 tool_call
  arguments 能否 json.loads,不能则丢弃整轮(不 append/不记账)重 roll(≤3 次),
  最后一次降级 _nonstream_once(provider 服务端拼,绕开流式错位)。流式收集逻辑
  抽成 _collect_stream_once,正常路径不变。
- core/executor_host.py / core/sandbox/tool_runner.py:缺必填参数早返
  「缺少必填参数 [...];请带齐 [...] 重新调用」,替掉暴露内部签名的
  TypeError missing N required positional arguments(host + docker 两路覆盖)。
- 文档:PROGRESS.md 加 2026-06-06 条;RUN.md 故障兜底加一行。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-06-06 20:51:45 +08:00
parent 72ae41e122
commit 8a7e0cd233
5 changed files with 106 additions and 4 deletions

View File

@ -21,6 +21,10 @@
## 已完成关键能力 ## 已完成关键能力
### 2026-06-06
- **修 deepseek-v4-flash 大参数工具调用 arguments 损坏 → loop 畸形重试 + 非流式兜底**:用户报"测试docx"任务里 zcbot 回 `[Error] bad arguments to write: WriteTool.execute() missing 2 required positional arguments`。实证定位(dump 失败 task 全量 messages):**大参数(≈710K 字符)的 write/run_python 偶发把别处内容碎片错位粘进 `arguments` 开头**(如 `].cells[1].merge(...{"path":...}`),`json.loads` 直接失败;有时退化成空 `{}` → execute 缺参报 TypeError。**根因双层**:① 上游 deepseek-v4-flash 流式 delta 偶发错位(隔离复现 16/16 全干净,说明概率低);② 真正放大成灾的是 **loop 把损坏的 assistant 消息原样入库 + 每轮重发 → 模型学坏的投毒级联**(失败 task 里大半 write 连锁失败)。读 litellm `stream_chunk_builder` 源码排除"content 混进 args"(content 与 tool_args 两趟独立合并);批量验证非流式 8/8、流式 8/8 在干净上下文均不复现 → 确认是间歇上游抖动 + loop 零容错。**修法**(`core/loop.py`):`_stream_llm` 重构成「拉一轮 → `_malformed_tool_calls` 校验 tool_call arguments 能否 `json.loads` → 不能则**丢弃整轮(不 append/不记账)重 roll**」,最多 3 次;最后一次降级 `_nonstream_once`(provider 服务端拼 tool_calls,绕开流式错位,content 整段补 emit)。断投毒环 + 不依赖猜准上游成因 + 不动正常路径。**backstop**:`executor_host.py` / `sandbox/tool_runner.py` 缺必填参数(空 `{}`)早返 `缺少必填参数 [...];请带齐 [...] 重新调用`,替掉暴露内部签名的 `missing N required positional arguments`。重试消耗 token 不单独记账(罕见路径)。tests 全过(唯一失败 `test_static_vendor::formatContextStats` 是前端 ES module 化遗留,与本改无关)。
### 2026-06-05 ### 2026-06-05
- **前端模块化 Step 1:`dev.html` 单文件拆零构建 ES module(叶子优先)**:`web/static/dev.html` 原 4087 行(纯原生 JS、手写 `state` + 手动 DOM、零内联 `onclick``addEventListener`、唯一 `window.*` 是比较非赋值)。定方案「1 拆文件 → 2 后续引 Alpine/petite-vue 局部响应式 → 3 永不上 Vue+构建链」,本步只做 1。抽出 4 个无依赖叶子模块到 `web/static/js/`:`state.js`(`state` 单例 + `LS_*` + `EMBED*`)、`format.js`(escapeHtml/humanSize/fmtTokens/fmtCost/usage 系列等纯格式化)、`dom.js`(`$` + 浮层菜单 showMenu/hideMenu,import escapeHtml)、`api.js`(`api()` Bearer 封装,import state)、`markdown.js`(renderMd/highlightIn,依赖 vendor 全局)。剩余主体(login→boot,原 13874084)整体落 `main.js` 并 import 上述叶子;`dev.html` 内联大 `<script>` 换成一行 `<script type="module" src="js/main.js">`,降到 1121 行。**逻辑零改动,纯剪切+连线**。`app.py` 加 `mimetypes.add_type("text/javascript", ".js")` 兜底(防 Windows 把 `.js` 判 text/plain 致 module 拒执行;本机实测 `.js`→application/javascript 本就 OK,纯防御)。校验:6 模块 `node --check` 全过 + 无私有符号(`_menuItems`/`_embedQS`)泄漏到 main。后续步骤将从 main.js 把 login/tasks/stream/files/preview 等逐个剥成独立模块(tasks↔stream 循环依赖靠 ES live binding 解)。 - **前端模块化 Step 1:`dev.html` 单文件拆零构建 ES module(叶子优先)**:`web/static/dev.html` 原 4087 行(纯原生 JS、手写 `state` + 手动 DOM、零内联 `onclick``addEventListener`、唯一 `window.*` 是比较非赋值)。定方案「1 拆文件 → 2 后续引 Alpine/petite-vue 局部响应式 → 3 永不上 Vue+构建链」,本步只做 1。抽出 4 个无依赖叶子模块到 `web/static/js/`:`state.js`(`state` 单例 + `LS_*` + `EMBED*`)、`format.js`(escapeHtml/humanSize/fmtTokens/fmtCost/usage 系列等纯格式化)、`dom.js`(`$` + 浮层菜单 showMenu/hideMenu,import escapeHtml)、`api.js`(`api()` Bearer 封装,import state)、`markdown.js`(renderMd/highlightIn,依赖 vendor 全局)。剩余主体(login→boot,原 13874084)整体落 `main.js` 并 import 上述叶子;`dev.html` 内联大 `<script>` 换成一行 `<script type="module" src="js/main.js">`,降到 1121 行。**逻辑零改动,纯剪切+连线**。`app.py` 加 `mimetypes.add_type("text/javascript", ".js")` 兜底(防 Windows 把 `.js` 判 text/plain 致 module 拒执行;本机实测 `.js`→application/javascript 本就 OK,纯防御)。校验:6 模块 `node --check` 全过 + 无私有符号(`_menuItems`/`_embedQS`)泄漏到 main。后续步骤将从 main.js 把 login/tasks/stream/files/preview 等逐个剥成独立模块(tasks↔stream 循环依赖靠 ES live binding 解)。

1
RUN.md
View File

@ -705,6 +705,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` / `[Error] bad arguments to write: ... missing required` | deepseek-v4-flash **大参数工具调用(大 write/run_python,≈7K+ 字符)偶发把内容碎片错位粘进 arguments 或退化成空 `{}`**(上游流式抖动)。`core/loop.py` 已自动兜底:畸形参数丢弃整轮重 roll(≤3 次)+ 最后一次降级非流式。仍频繁撞 → 引导模型**把大文件拆小 / 用 run_python 分块写**,或换 `deepseek_v4.pro`。前端看到 warn「工具调用参数损坏…重试」即此机制在生效 |
--- ---

View File

@ -32,6 +32,17 @@ class HostExecutor(Executor):
tool = self._tools.get(name) tool = self._tools.get(name)
if tool is None: if tool is None:
return ToolResult(content=f"[Error] unknown tool: {name}", exit_code=2) return ToolResult(content=f"[Error] unknown tool: {name}", exit_code=2)
# 缺必填参数早返清晰错误,而非让 execute 抛 `missing N required positional
# arguments` 这种暴露内部签名的 TypeError(空 args 多由上游 tool_call 损坏导致,
# _stream_llm 已重试兜底;漏到这里就给模型一句能照做的话)。
required = (getattr(tool, "parameters", {}) or {}).get("required") or []
missing = [k for k in required if k not in args]
if missing:
return ToolResult(
content=f"[Error] bad arguments to {name}: 缺少必填参数 {missing};"
f"请带齐 {required} 重新调用",
exit_code=2,
)
try: try:
result = tool.execute(**args) result = tool.execute(**args)
except TypeError as e: except TypeError as e:

View File

@ -46,6 +46,31 @@ def _extract_delta_content(chunk: Any) -> Optional[str]:
return None return None
def _malformed_tool_calls(response: Any) -> List[str]:
"""检出 arguments 损坏(JSON 解析不了)的 tool_call,返回 [name(len=N), ...]。
背景:deepseek-v4-flash 大参数工具调用偶发畸形 流式 delta 错位把别处的内容
碎片粘到 arguments 开头( `].cells[1].merge(...{"path":...}`),拼回来后 JSON
解析直接失败这种是上游瞬时抖动,不该入库污染上下文,调用方据此丢弃整轮重 roll
只看解析失败;空字符串 / 合法空对象不算畸形(交给 executor 按缺参数处理)
"""
try:
msg = response.choices[0].message
except Exception:
return []
bad: List[str] = []
for tc in (getattr(msg, "tool_calls", None) or []):
raw = (getattr(tc.function, "arguments", None) or "").strip()
if not raw:
continue
try:
json.loads(raw)
except (json.JSONDecodeError, ValueError):
bad.append(f"{tc.function.name}(len={len(raw)})")
return bad
def _usage_to_dict(usage: Any) -> dict: def _usage_to_dict(usage: Any) -> dict:
if not usage: if not usage:
return {} return {}
@ -221,20 +246,53 @@ class AgentLoop:
self._emit({"type": "done"}) self._emit({"type": "done"})
return "[reached max iterations]" return "[reached max iterations]"
# 工具参数畸形时,丢弃整轮重 roll 的最大次数;第 _MAX_MALFORMED_RETRIES 次(即最后
# 一次)降级走非流式(provider 服务端拼 tool_calls,绕开流式 delta 错位)。实测大参数
# 工具调用偶发连续两次畸形,故留够重试余量。
_MAX_MALFORMED_RETRIES = 3
def _stream_llm(self) -> Tuple[Optional[Any], bool]: def _stream_llm(self) -> Tuple[Optional[Any], bool]:
"""流式拉一轮 LLM,chunk 间 poll cancel,content delta 即时 emit。 """拉一轮 LLM 并保证返回的 tool_call arguments 可解析
返回 (response, cancelled_mid_stream): 返回 (response, cancelled_mid_stream):
- 正常完结 (response, False);response litellm.stream_chunk_builder 拼回, - 正常完结 (response, False);response shape 与非流式 completion() 等价
shape 与非流式 completion() 等价(choices[0].message + usage) (choices[0].message + usage)
- 中途 cancel (None, True);已收 chunk 丢弃,内层 generator finally 关闭底层连接 - 中途 cancel (None, True);已收 chunk 丢弃,内层 generator finally 关闭底层连接
畸形重试:deepseek-v4-flash 大参数工具调用偶发把内容碎片错位粘进 arguments,拼回
JSON 解析失败这种损坏一旦入库会被每轮重发诱导模型继续学坏(投毒级联)
故拼回后先校验 tool_call arguments 能否解析:不能 丢弃整轮( append/不记账)
roll;连续失败到最后一次降级非流式兜底重试消耗的 token 不单独记账(罕见路径)
""" """
chunks: List[Any] = []
llm_messages, context_stats = prepare_messages_with_stats(self.session.messages) llm_messages, context_stats = prepare_messages_with_stats(self.session.messages)
self._emit({ self._emit({
"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()},
}) })
for attempt in range(self._MAX_MALFORMED_RETRIES + 1):
use_nonstream = attempt == self._MAX_MALFORMED_RETRIES
if use_nonstream:
response = self._nonstream_once(llm_messages)
else:
response, cancelled = self._collect_stream_once(llm_messages)
if cancelled:
return None, True
bad = _malformed_tool_calls(response)
if not bad:
return response, False
self._emit({
"type": "warn",
"msg": f"工具调用参数损坏 {bad},丢弃本轮重试 ({attempt + 1}/{self._MAX_MALFORMED_RETRIES})",
})
# 非流式兜底仍畸形(理论极罕见):交还给 _execute_tool_call 的 invalid-JSON 分支
# 优雅返错给模型,而非在此死循环。
return response, False
def _collect_stream_once(self, llm_messages: List[dict]) -> Tuple[Optional[Any], bool]:
"""跑一次流式:攒 chunk + content delta 即时 emit,拼回完整 response。
返回 (response, cancelled_mid_stream)"""
chunks: List[Any] = []
stream = self.llm.chat_stream( stream = self.llm.chat_stream(
messages=llm_messages, messages=llm_messages,
tools=self.executor.schemas(), tools=self.executor.schemas(),
@ -266,6 +324,22 @@ 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:
"""非流式兜底:provider 服务端一次性拼好 tool_calls,绕开流式 delta 错位。
没有 chunk cancel,content 也拿不到 delta 整段 text 一次性补 emit"""
response = self.llm.chat(
messages=llm_messages,
tools=self.executor.schemas(),
reasoning_effort=self.caps.default_reasoning_effort or None,
)
try:
text = getattr(response.choices[0].message, "content", None)
if text:
self._emit({"type": "text", "delta": text})
except Exception:
pass
return response
def _execute_tool_call(self, tc: Any) -> str: def _execute_tool_call(self, tc: Any) -> str:
name = tc.function.name name = tc.function.name
raw_args = tc.function.arguments or "{}" raw_args = tc.function.arguments or "{}"

View File

@ -54,6 +54,18 @@ def main() -> int:
return 2 return 2
cls = TOOLS[name] cls = TOOLS[name]
# 缺必填参数早返清晰错误,而非让 execute 抛暴露内部签名的 TypeError(空 args 多由
# 上游 tool_call 损坏导致,AgentLoop 已重试兜底;漏到这里给模型一句能照做的话)。
required = (getattr(cls, "parameters", {}) or {}).get("required") or []
missing = [k for k in required if k not in args]
if missing:
print(
f"[Error] bad arguments to {name}: 缺少必填参数 {missing};"
f"请带齐 {required} 重新调用",
file=sys.stderr,
)
return 2
tool = cls(base_dir=Path(os.getcwd()), user_root=Path("/workspace")) tool = cls(base_dir=Path(os.getcwd()), user_root=Path("/workspace"))
try: try:
result = tool.execute(**args) result = tool.execute(**args)