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:
parent
72ae41e122
commit
8a7e0cd233
|
|
@ -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):**大参数(≈7–10K 字符)的 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,原 1387–4084)整体落 `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,原 1387–4084)整体落 `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
1
RUN.md
|
|
@ -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「工具调用参数损坏…重试」即此机制在生效 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
82
core/loop.py
82
core/loop.py
|
|
@ -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 "{}"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue