design: 加 §8.1 seedream i2i + vision 后续步骤 + probe 实测 base64 通

用户场景"seedream 出图→基于该图二次修改"/"上传外部参考图让 agent 据此干活"
两条主模型 DeepSeek V4 纯文本覆盖不了。详评 3 方案选 E+C 组合(seedream
加 reference_images 走 5.0 i2i + 新 tools/look_at_image.py 走豆包 Seed 1.6
vision tool 调度),否决 A(换主模型降 code/tool calling 质量+改 loop/memory
工程面 5×)/ B(后台隐式 vision 路由失 agentic 控制+token 浪费)。

实测 scripts/probe_seedream_i2i.py:豆包 Seedream 5.0 /images/generations
接受 image_urls=[data:image/png;base64,...] 作 i2i 输入,200 返回新图 TOS URL
+ usage.generated_images=1。约束:输出 size≥3686400 像素(~1920²),单张参考
≤10MB,最多 14 张。**内网部署无需对象存储中介**,排除最大工程不确定性。

E+C 实施清单/风险/升级到 A 的信号已落 DESIGN §8.1,本版仅 probe+design,
tool 与 prompt 改造未启动。RUN.md / SKILL_LIST.md 不动(无 CLI/env/skill 变化)。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-05-29 12:47:04 +08:00
parent e6eebbc9ad
commit 4bd074079a
3 changed files with 157 additions and 1 deletions

View File

@ -518,6 +518,40 @@ zcbot-sandbox image 已 ~1.5G(python deps + chromium + nodejs + mermaid-cli),后
--- ---
## 8. 未来步骤(草案,status=design)
### 8.1 图像理解 + Seedream i2i(2026-05-29)
**缺口**:DeepSeek V4 主模型纯文本无视觉;`tools/seedream.py` 只 t2i;用户场景"seedream 出图 → 基于该图二次修改" / "上传外部参考图让 agent 据此干活"两条路径都未覆盖。
**选 E + C 组合**:`tools/seedream.py` 加 `reference_images` 参数走 seedream 5.0 i2i + 新增 `tools/look_at_image.py` 走豆包 Seed 1.6 vision 单图理解。
**为什么不选 A(主模型换多模态)**:DeepSeek V4 的 code / tool calling 质量是 zcbot 主路径核心,换豆包 Seed 1.6 当主 chat 模型降 code 能力 + 拉低 tool calling 稳定性,且要改 `core/loop.py` 引入 multimodal content list 路径 + `core/memory.py` vision-aware 压缩 — 工程面 5× 且破坏现有架构。
**为什么不选 B(后台 vision 路由)**:用户每条消息隐式调一次 vision 描述 → 多烧 token + 多 1 跳延迟;DeepSeek 失去"自决何时看图"的 agentic 控制权;hidden prompt 影响描述质量,debug 难。
**为什么 E + C 协作**:E 覆盖"改已生成图"(seedream 端到端无损像素级);C 覆盖"理解外部图"(DeepSeek 自决何时调,按需烧 token)。改动面集中在 **2 个 tool + 1 个 prompt 段 + 1 个 yaml 段**,**不动 loop / llm / capabilities / DB schema / 前端**。
**关键实测(2026-05-29 `scripts/probe_seedream_i2i.py`)**:Seedream 5.0 (`doubao-seedream-5-0-260128`) `/images/generations` 接受 `image_urls=["data:image/png;base64,..."]`,200 返回新图 TOS URL + `usage.generated_images=1`。约束:输出 `size` ≥3686400 像素(~1920²),单张参考 ≤10MB,最多 14 张。**base64 通路成立 → 内网部署无需对象存储中介,排除最大工程不确定性**。
**实施清单(待启动)**:
1. `config/media/doubao.yaml``vision: seed_1_6:` 段(model_id + endpoint + 视觉 token 单价);顺手核对 seedream image 单价 0.22 vs 实测 ~0.25 CNY
2. `tools/seedream.py``reference_images: array[str]` + `seed: int` 参数;路径校验在 workdir 内 + ≤10MB + 扩展名白名单(png/jpg/webp);meta.json 多记 reference_images
3. `tools/look_at_image.py` 新建:走 `ark_client` POST `/chat/completions` OpenAI 多模态格式(`{"type":"image_url","image_url":{"url":"data:..."}}`);`record_chat_usage(model_profile="doubao.seed_1_6_vision")`,不入 `images_per_day` 配额
4. `prompts/system/general_v1.md` 加图像协作引导段(改图 → seedream i2i;问图/读图 → look_at_image)
5. 端到端 smoke:`scripts/smoke_seedream_i2i.py` + `scripts/smoke_look_at_image.py`
6. (可选)前端 `dev.html` seedream 产物 chip 加"基于此图改"按钮(把路径塞下一条 textarea hint)
**已知风险**:
- 多张参考图(2-14)的角色定义(主体 vs 风格 vs 局部)靠 prompt 经验,**v1 只支持单张**;multi-ref 留 v2
- 豆包 Seed 1.6 vision tokens 单价(in / out 分档)待 ARK 控制台查
- DeepSeek 主动调 `look_at_image` 的可靠性需前几轮真用例里观察,不靠就在 prompt 加一句更明确引导;不过度工程
- ARK 文档强调 image_urls 官方推荐 URL,base64 实测可行但**未承诺长期稳定**;若未来 ARK 收紧,降级方案 = 火山 TOS 上传 5 分钟 → URL(引入 TOS SDK)
**升级到 A(主模型多模态)的信号**:用户明确要求"我说话同时贴图,模型直接读图回话",或多模态对话历史(多轮带图)成为高频需求 — 当前 E + C 假设是"图像是工具调用对象"而非"对话上下文";真高频需要"图也是消息内容"时再升 A。
---
## 附录:DeepSeek V4 关键事实(2026-04-24) ## 附录:DeepSeek V4 关键事实(2026-04-24)
- **V4-Pro**:1.6T / 49B 激活,1M context,SWE-Bench 80.6 / Terminal-Bench 67.9 / MCPAtlas 73.6 - **V4-Pro**:1.6T / 49B 激活,1M context,SWE-Bench 80.6 / Terminal-Bench 67.9 / MCPAtlas 73.6

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-05-29(删 docker exec argv 里的 setsid 修延迟 stdout 丢失 + `_run_subprocess` 重写修 communicate poll loop bug + 3 个 SKILL.md sandbox 凭证可用性校准 + web 端 tool_call 标题行改显中文活动描述并修 args 字段 bug) 最后更新:2026-05-29(删 docker exec argv 里的 setsid 修延迟 stdout 丢失 + `_run_subprocess` 重写修 communicate poll loop bug + 3 个 SKILL.md sandbox 凭证可用性校准 + web 端 tool_call 标题行改显中文活动描述并修 args 字段 bug + Seedream 5.0 i2i base64 通路实测 + DESIGN §8.1 落 i2i/vision 后续步骤)
--- ---
@ -23,6 +23,7 @@
### 2026-05-29 ### 2026-05-29
- **Seedream 5.0 i2i base64 通路 probe + DESIGN §8.1 后续步骤落册**:用户场景"调 seedream 出图 → 基于该图二次修改" / "上传外部参考图让 agent 据此干活"两条路径,主模型 DeepSeek V4 纯文本覆盖不了。详评 3 方案后选 **E + C 组合**(`tools/seedream.py` 加 `reference_images` 参数走 seedream 5.0 i2i + 新增 `tools/look_at_image.py` 走豆包 Seed 1.6 vision tool 调度),否决 A(换豆包当主 chat,降 code / tool calling 质量 + 改 loop/memory 工程面 5×)/ B(后台隐式 vision 路由,失 agentic 控制 + 描述质量黑盒 + token 浪费)。**写探针 `scripts/probe_seedream_i2i.py` 实测**:豆包 Seedream 5.0(`doubao-seedream-5-0-260128`)`/images/generations` endpoint **接受 `image_urls=["data:image/png;base64,..."]`**,200 返回新图 TOS URL + `usage.generated_images=1`(约束:输出 `size` ≥3686400 像素 / ~1920²,单张参考 ≤10MB,最多 14 张);base64 通路成立 → **内网部署无需对象存储中介**,排除最大工程不确定性。**E+C 实施清单 / 风险 / 升级到 A 的信号已落 DESIGN §8.1,本版仅 probe + design,tool 与 prompt 改造未启动**。
- **web 端 tool_call 标题行改显中文活动描述(`dev.html`)**:用户反馈 web 端工具调用只显示 `工具调用:run_python` / `shell` 等工具名,看不出"在干啥"。**根因有个真 bug**:`dev.html` 实时流分支(`tool_call`)读 `ev.data.arguments`,但后端 `core/loop.py::_execute_tool_call` emit 的字段叫 `args`(已解析 dict)+ `args_preview`(截 200 字),字段名对不上 → 前端拿到的永远是空串,`<pre>` 是空的、连带 `extractArtifactRels(argsStr,...)` 也抽不到产物路径。**改法**:① 新增 `toolActivityLabel(name, args)` helper(挨着 `_workingDirName`),按 12 个工具的"关键代表参数"套中文动词 + 截断值:read/write/edit→`读取/写入/编辑文件: {path}`、glob→`查找文件: {pattern}`、grep→`搜索内容: {pattern}`、shell→`执行命令: {command}`、run_python→`运行 Python: {code}`、web_fetch→`抓取网页: {url}`、web_search→`联网搜索: {query}`、load_skill→`加载技能: {name}`、seedream/seedance→`生成图像/视频: {prompt}`,未知工具回退到 `name {JSON截断}``工具调用: name`;clip 把空白压一行 + 超长加 `…`。② 实时流分支 `ev.data.arguments``ev.data.args`(修字段 bug)并把 `<summary>` 文案换成 label。③ 历史消息回放分支(`p.tool_calls`,LiteLLM 格式 `tc.function.arguments` 是 JSON 字符串)同步:先 `JSON.parse``argsObj` 再生成 label,保持实时 / 历史一致。完整参数仍在折叠 `<pre>` 里,展开可看。**选型**:用前端静态模板(零成本 / 不走模型 / 立即生效),否决"让模型每次调用前产出一句中文意图"(改 prompt + 每调用烧 token + 依赖模型配合)。**纯前端改**,`DESIGN.md` / `RUN.md` / `SKILL_LIST.md` 不动(无架构 / CLI / env / skill 变化)。**生效**:刷新 web 页面即可,无需重启后端。 - **web 端 tool_call 标题行改显中文活动描述(`dev.html`)**:用户反馈 web 端工具调用只显示 `工具调用:run_python` / `shell` 等工具名,看不出"在干啥"。**根因有个真 bug**:`dev.html` 实时流分支(`tool_call`)读 `ev.data.arguments`,但后端 `core/loop.py::_execute_tool_call` emit 的字段叫 `args`(已解析 dict)+ `args_preview`(截 200 字),字段名对不上 → 前端拿到的永远是空串,`<pre>` 是空的、连带 `extractArtifactRels(argsStr,...)` 也抽不到产物路径。**改法**:① 新增 `toolActivityLabel(name, args)` helper(挨着 `_workingDirName`),按 12 个工具的"关键代表参数"套中文动词 + 截断值:read/write/edit→`读取/写入/编辑文件: {path}`、glob→`查找文件: {pattern}`、grep→`搜索内容: {pattern}`、shell→`执行命令: {command}`、run_python→`运行 Python: {code}`、web_fetch→`抓取网页: {url}`、web_search→`联网搜索: {query}`、load_skill→`加载技能: {name}`、seedream/seedance→`生成图像/视频: {prompt}`,未知工具回退到 `name {JSON截断}``工具调用: name`;clip 把空白压一行 + 超长加 `…`。② 实时流分支 `ev.data.arguments``ev.data.args`(修字段 bug)并把 `<summary>` 文案换成 label。③ 历史消息回放分支(`p.tool_calls`,LiteLLM 格式 `tc.function.arguments` 是 JSON 字符串)同步:先 `JSON.parse``argsObj` 再生成 label,保持实时 / 历史一致。完整参数仍在折叠 `<pre>` 里,展开可看。**选型**:用前端静态模板(零成本 / 不走模型 / 立即生效),否决"让模型每次调用前产出一句中文意图"(改 prompt + 每调用烧 token + 依赖模型配合)。**纯前端改**,`DESIGN.md` / `RUN.md` / `SKILL_LIST.md` 不动(无架构 / CLI / env / skill 变化)。**生效**:刷新 web 页面即可,无需重启后端。
- **删 `_exec_shell` / `_exec_python` argv 里的 `setsid` 修 docker exec 延迟 stdout 丢失**:上一条 `_run_subprocess` 重写后用户实测 LLM 拿 `cement energy efficiency` 跑 paper_server 检索仍返空 `[exit 0]` 8 字符,且 `print(f"共命中 {len(papers)} 条结果\n")` 这种必有输出的代码也丢。**根因 = `setsid`,不是 `_run_subprocess` 的 poll loop**(上一条修对了一个独立的 bug,但不是用户当下症状的元凶)。**实证差异**:`docker exec ... setsid python -c "import time; time.sleep(2); print('hello')"` 等满 2.06s 但输出空;同条件去掉 setsid `docker exec ... python -c "..."` 等满 2.08s + 输出 `hello`。`setsid` 调 `setsid()` syscall 把进程变 new session leader without controlling terminal **之后** execvp(python),docker exec / runc 的 stdio attach 对"调用方变 session leader"敏感(具体哪一层没深挖到 runc 源码,经验上 docker exec + setsid + 延迟输出 = stdout 数据被截断,业界踩过的坑)。短输出(`print('hello')` 瞬时完成)能在窗口内漏出去,延迟输出(`search()` 等 httpx 1-2s)就全丢 —— 完美解释为什么 LLM 简单 `print(version)` 测试时部分输出能回但真业务调用全空。**为什么之前 host 侧 `docker exec ... python -c "from skills.research.paper import search; ..."` 2.94s 拿 10 条成功**:那条**没有** setsid,docker exec 等的就是 python 本身,3s 全程都阻塞读 stdout 不会丢。`setsid` 历史上是 §7.5 Stage C **Step 3b PGID kill 协议**铺路用的(PROGRESS 上有"延后到外部用户开放前"这条 work item),**协议没实现的当下 setsid 是空头载荷 + 副作用**。**改法**:`core/executor_docker.py:141` 和 `:177` 各删 1 行的 `"setsid"`,argv 形态变 `docker exec ... <container> bash -c <cmd>` / `... python <script>`。**否决**:(a) `setsid --wait <prog>` —— 强制 setsid 等子退 + 透传 exit code,但需要 util-linux 2.32+ 且增加一层依赖;PGID kill 协议没做的当下没必要;(b) 包一层自己写的 wait wrapper —— 同上,过度工程;(c) 改 docker exec 加 `-t` 强制 tty —— 引入 \r\n 转换 + 影响输出清晰度,非生产做法。**测试**(`tests/test_executor_docker.py`):① 更新 `test_shell_invokes_docker_exec` 断言 `argv[container_idx+1:] == ["bash", "-c", "echo hello"]`(原 `["setsid", "bash", "-c", ...]`);② 更新 `test_run_python_tmp_script``argv[-3] == "setsid"` 断言;③ **新加 `test_run_subprocess_delayed_output_not_lost`**:`sys.executable, "-c", "import time; time.sleep(1); print('LATE')"` 真子进程跑,断 LATE 在 stdout 里 + `[exit 0]`(Windows skip);④ **新加 `test_argv_does_not_contain_setsid`**:patch Popen 抓两次 call(shell + run_python)的 argv,断 `"setsid" not in argv` 防回潮。**全套 19/19 PASS**(老 17 + 2 新)。**未来加 PGID kill 协议时**:不能裸 setsid 回潮 —— `test_argv_does_not_contain_setsid` 会挂,会强制实现者去查 PROGRESS 这条把 setsid 用对(`setsid --wait` 或 wrapper)。**部署生效**:`git pull && systemctl restart zcbot`,sandbox 容器不用 rm。**经验值更新**:这次诊断走了弯路 —— 上一条 `_run_subprocess` 重写虽然修了一个真 bug(communicate poll loop 违反 API + bash block-buffered chunk 丢),但**不是用户当下症状的元凶**,setsid 才是。两个 bug 都贡献"8 字符 `[exit 0]`"的部分场景:poll loop 影响多 chunk 输出收集,setsid 影响延迟输出收集,各自独立,都得修。`DESIGN.md` 不动(纯实现 bug 修,§7.5 Step 3b PGID kill 协议状态没变,只是落实时改用 `setsid --wait` 而非裸 setsid,这是个实现细节不是架构变化);`RUN.md` 不动;`SKILL_LIST.md` 不动。 - **删 `_exec_shell` / `_exec_python` argv 里的 `setsid` 修 docker exec 延迟 stdout 丢失**:上一条 `_run_subprocess` 重写后用户实测 LLM 拿 `cement energy efficiency` 跑 paper_server 检索仍返空 `[exit 0]` 8 字符,且 `print(f"共命中 {len(papers)} 条结果\n")` 这种必有输出的代码也丢。**根因 = `setsid`,不是 `_run_subprocess` 的 poll loop**(上一条修对了一个独立的 bug,但不是用户当下症状的元凶)。**实证差异**:`docker exec ... setsid python -c "import time; time.sleep(2); print('hello')"` 等满 2.06s 但输出空;同条件去掉 setsid `docker exec ... python -c "..."` 等满 2.08s + 输出 `hello`。`setsid` 调 `setsid()` syscall 把进程变 new session leader without controlling terminal **之后** execvp(python),docker exec / runc 的 stdio attach 对"调用方变 session leader"敏感(具体哪一层没深挖到 runc 源码,经验上 docker exec + setsid + 延迟输出 = stdout 数据被截断,业界踩过的坑)。短输出(`print('hello')` 瞬时完成)能在窗口内漏出去,延迟输出(`search()` 等 httpx 1-2s)就全丢 —— 完美解释为什么 LLM 简单 `print(version)` 测试时部分输出能回但真业务调用全空。**为什么之前 host 侧 `docker exec ... python -c "from skills.research.paper import search; ..."` 2.94s 拿 10 条成功**:那条**没有** setsid,docker exec 等的就是 python 本身,3s 全程都阻塞读 stdout 不会丢。`setsid` 历史上是 §7.5 Stage C **Step 3b PGID kill 协议**铺路用的(PROGRESS 上有"延后到外部用户开放前"这条 work item),**协议没实现的当下 setsid 是空头载荷 + 副作用**。**改法**:`core/executor_docker.py:141` 和 `:177` 各删 1 行的 `"setsid"`,argv 形态变 `docker exec ... <container> bash -c <cmd>` / `... python <script>`。**否决**:(a) `setsid --wait <prog>` —— 强制 setsid 等子退 + 透传 exit code,但需要 util-linux 2.32+ 且增加一层依赖;PGID kill 协议没做的当下没必要;(b) 包一层自己写的 wait wrapper —— 同上,过度工程;(c) 改 docker exec 加 `-t` 强制 tty —— 引入 \r\n 转换 + 影响输出清晰度,非生产做法。**测试**(`tests/test_executor_docker.py`):① 更新 `test_shell_invokes_docker_exec` 断言 `argv[container_idx+1:] == ["bash", "-c", "echo hello"]`(原 `["setsid", "bash", "-c", ...]`);② 更新 `test_run_python_tmp_script``argv[-3] == "setsid"` 断言;③ **新加 `test_run_subprocess_delayed_output_not_lost`**:`sys.executable, "-c", "import time; time.sleep(1); print('LATE')"` 真子进程跑,断 LATE 在 stdout 里 + `[exit 0]`(Windows skip);④ **新加 `test_argv_does_not_contain_setsid`**:patch Popen 抓两次 call(shell + run_python)的 argv,断 `"setsid" not in argv` 防回潮。**全套 19/19 PASS**(老 17 + 2 新)。**未来加 PGID kill 协议时**:不能裸 setsid 回潮 —— `test_argv_does_not_contain_setsid` 会挂,会强制实现者去查 PROGRESS 这条把 setsid 用对(`setsid --wait` 或 wrapper)。**部署生效**:`git pull && systemctl restart zcbot`,sandbox 容器不用 rm。**经验值更新**:这次诊断走了弯路 —— 上一条 `_run_subprocess` 重写虽然修了一个真 bug(communicate poll loop 违反 API + bash block-buffered chunk 丢),但**不是用户当下症状的元凶**,setsid 才是。两个 bug 都贡献"8 字符 `[exit 0]`"的部分场景:poll loop 影响多 chunk 输出收集,setsid 影响延迟输出收集,各自独立,都得修。`DESIGN.md` 不动(纯实现 bug 修,§7.5 Step 3b PGID kill 协议状态没变,只是落实时改用 `setsid --wait` 而非裸 setsid,这是个实现细节不是架构变化);`RUN.md` 不动;`SKILL_LIST.md` 不动。
- **`core/executor_docker.py::_run_subprocess` 重写修 docker exec stdout 多 chunk 静默丢失 bug**:用户实测 LLM 在容器里调 `from skills.research.paper import search` / `shell echo "test"; ...; echo "done"` 拿到空 `[exit 0]` 8 字符,而 host 侧 `docker exec ... python -c "from skills.research.paper import search; r=search(...)"` 同 query 2.94s 拿 10 条切题结果 —— 网络 / DNS / paper_server / helper / httpx 全清白,**问题在 tool wrapper 自己**。**根因**:旧实现 `while True: try: proc.communicate(input=stdin, timeout=0.5) except TimeoutExpired: ...` 在 poll loop 里反复调 `communicate()`,**违反 `subprocess` API 假设**(`communicate` 文档明说 "should be called only once") + 配合 `setsid bash -c "..."` 的 block-buffered stdout(pipe 而非 tty 触发 4K block buffering)在多 chunk 输出时序下 chunk 静默丢失。具体路径:`echo "test"` 第一个 token 几乎 buffer 还没装就到 `setsid bash` 收尾 flush 那一刻被读到 ; `timeout 3 python3 ...` 子进程 + 后续 `echo "done"` 走的是 setsid 子会话的二级 buffer,communicate 在 0.5s 轮询窗口里要么完全读不到要么读半截,内部 `self._fileobj2output` 状态在反复 TO 后某些 Python 版本下不连续。**重写实现(D 候选,4 候选里选最小补丁)**:① 入口 inline 查一次 `cancel_check`,True 立即返不起 Popen(同步快路径 + 消除单测 race);② 单次 `proc.communicate(input=stdin, timeout=timeout)`,违反 API 的 poll loop 彻底删;③ cancel 检查移到侧 daemon 线程,周期 `_CANCEL_POLL_INTERVAL_S=0.2`(模块常量,单测 patch 0.02 加速)poll `cancel_check`,命中即 `cancel_hit.set() + proc.kill()`;④ TimeoutExpired 分支 `kill + 二次 communicate()``self._fileobj2output` 续读累积 chunks 不丢已读;⑤ cancel 优先于 timeout(canceller 设了 hit 即使 communicate 也抛 TO 时优先返 cancelled)。**否决**:(A) 2 reader 线程裸 drain stdout/stderr.read() —— ~40 行,可选但 D 最小;(B) `selectors.PollSelector` 非阻塞 read —— ~50 行,Windows caveat(咱们 Linux 部署不踩,但代码上多一层平台分支);(C) 整链路 sync→async —— 大手术,不值。**回归测试**(`tests/test_executor_docker.py`):① `test_shell_cancel``test_shell_cancel_inline_fastpath`(入口快路径,Popen 不起)+ `test_shell_cancel_via_canceller_thread`(cancel_check 第 1 次 False 让 inline 放行第 2 次起 True 让侧线程触发,threading.Event 同步 mock kill);② **新加 `test_run_subprocess_collects_multi_chunk_output`** 起真子进程 `bash -c 'echo A; sleep 0.6; echo B; sleep 0.6; echo C'` 断 A/B/C 全在结果里(Windows skip,Linux CI/部署跑),这条 case 在旧实现下必挂、新实现必过 —— 直接锁死本 bug 回归;③ `test_shell_timeout` / `test_fs_tool_timeout` 移除已死的 `time.monotonic` patch(新代码不用 time.monotonic 跟踪 elapsed,改靠 `communicate(timeout=N)` 自带累计)。**结果 17/17 PASS**(老 15 + 拆 1 加 2 = 17,multi-chunk 那条 Windows skip 计入)。**部署生效**:重启 web 进程 + 老 sandbox 容器无需 rm(代码改在 host 侧 executor,容器本身行为不变,新请求进 new wrapper 即用)。**行为变化**:cancel 响应延迟 ~500ms → ~200ms(侧线程 wait 0.2s 改善);timeout 错误文案不变;线程开销 per call 多 1 个 daemon thread 只在 `cancel_check is not None` 时起,忽略不计。`DESIGN.md` 不动(无架构变化,纯实现 bug 修;§7.5 Stage C Step 3b "PGID kill 协议" 跟本 bug 正交,该 work item 延后到外部用户开放前的状态没变);`RUN.md` 不动(无 CLI / env / 文件布局变化);`SKILL_LIST.md` 不动(skill 列表无变化)。 - **`core/executor_docker.py::_run_subprocess` 重写修 docker exec stdout 多 chunk 静默丢失 bug**:用户实测 LLM 在容器里调 `from skills.research.paper import search` / `shell echo "test"; ...; echo "done"` 拿到空 `[exit 0]` 8 字符,而 host 侧 `docker exec ... python -c "from skills.research.paper import search; r=search(...)"` 同 query 2.94s 拿 10 条切题结果 —— 网络 / DNS / paper_server / helper / httpx 全清白,**问题在 tool wrapper 自己**。**根因**:旧实现 `while True: try: proc.communicate(input=stdin, timeout=0.5) except TimeoutExpired: ...` 在 poll loop 里反复调 `communicate()`,**违反 `subprocess` API 假设**(`communicate` 文档明说 "should be called only once") + 配合 `setsid bash -c "..."` 的 block-buffered stdout(pipe 而非 tty 触发 4K block buffering)在多 chunk 输出时序下 chunk 静默丢失。具体路径:`echo "test"` 第一个 token 几乎 buffer 还没装就到 `setsid bash` 收尾 flush 那一刻被读到 ; `timeout 3 python3 ...` 子进程 + 后续 `echo "done"` 走的是 setsid 子会话的二级 buffer,communicate 在 0.5s 轮询窗口里要么完全读不到要么读半截,内部 `self._fileobj2output` 状态在反复 TO 后某些 Python 版本下不连续。**重写实现(D 候选,4 候选里选最小补丁)**:① 入口 inline 查一次 `cancel_check`,True 立即返不起 Popen(同步快路径 + 消除单测 race);② 单次 `proc.communicate(input=stdin, timeout=timeout)`,违反 API 的 poll loop 彻底删;③ cancel 检查移到侧 daemon 线程,周期 `_CANCEL_POLL_INTERVAL_S=0.2`(模块常量,单测 patch 0.02 加速)poll `cancel_check`,命中即 `cancel_hit.set() + proc.kill()`;④ TimeoutExpired 分支 `kill + 二次 communicate()``self._fileobj2output` 续读累积 chunks 不丢已读;⑤ cancel 优先于 timeout(canceller 设了 hit 即使 communicate 也抛 TO 时优先返 cancelled)。**否决**:(A) 2 reader 线程裸 drain stdout/stderr.read() —— ~40 行,可选但 D 最小;(B) `selectors.PollSelector` 非阻塞 read —— ~50 行,Windows caveat(咱们 Linux 部署不踩,但代码上多一层平台分支);(C) 整链路 sync→async —— 大手术,不值。**回归测试**(`tests/test_executor_docker.py`):① `test_shell_cancel``test_shell_cancel_inline_fastpath`(入口快路径,Popen 不起)+ `test_shell_cancel_via_canceller_thread`(cancel_check 第 1 次 False 让 inline 放行第 2 次起 True 让侧线程触发,threading.Event 同步 mock kill);② **新加 `test_run_subprocess_collects_multi_chunk_output`** 起真子进程 `bash -c 'echo A; sleep 0.6; echo B; sleep 0.6; echo C'` 断 A/B/C 全在结果里(Windows skip,Linux CI/部署跑),这条 case 在旧实现下必挂、新实现必过 —— 直接锁死本 bug 回归;③ `test_shell_timeout` / `test_fs_tool_timeout` 移除已死的 `time.monotonic` patch(新代码不用 time.monotonic 跟踪 elapsed,改靠 `communicate(timeout=N)` 自带累计)。**结果 17/17 PASS**(老 15 + 拆 1 加 2 = 17,multi-chunk 那条 Windows skip 计入)。**部署生效**:重启 web 进程 + 老 sandbox 容器无需 rm(代码改在 host 侧 executor,容器本身行为不变,新请求进 new wrapper 即用)。**行为变化**:cancel 响应延迟 ~500ms → ~200ms(侧线程 wait 0.2s 改善);timeout 错误文案不变;线程开销 per call 多 1 个 daemon thread 只在 `cancel_check is not None` 时起,忽略不计。`DESIGN.md` 不动(无架构变化,纯实现 bug 修;§7.5 Stage C Step 3b "PGID kill 协议" 跟本 bug 正交,该 work item 延后到外部用户开放前的状态没变);`RUN.md` 不动(无 CLI / env / 文件布局变化);`SKILL_LIST.md` 不动(skill 列表无变化)。

View File

@ -0,0 +1,121 @@
"""Probe: seedream 5.0 i2i 是否接受 base64 data: URI 作 image_urls 项。
跑法: .venv/Scripts/python.exe scripts/probe_seedream_i2i.py
**base64 接受 -> 真出图,产生 ~0.22 CNY 费用**;base64 被拒 -> ArkError,不计费
目的:确认 i2i base64 通路是否可行(zcbot 内网部署,无法提供公网 URL ARK 反向 fetch)
如果 base64 后续 seedream i2i tool 可以直接用 base64,工程简单;
如果 base64 要查 ARK 是否有"上传文件拿 file_id"接口或外接对象存储
输出:
- HTTP 状态 / 响应 JSON(节选)
- 结论标签 [RESULT] base64-supported / base64-rejected / unclear
"""
from __future__ import annotations
import base64
import io
import json
import os
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
# 读 .env
env_file = ROOT / ".env"
if env_file.exists():
for line in env_file.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, _, v = line.partition("=")
os.environ.setdefault(k.strip(), v.strip())
from PIL import Image
from core.ark_client import ArkClient, ArkConfig, ArkError
def make_test_png() -> bytes:
"""256x256 蓝底 PNG,中间一个红圆 —— prompt 改色易验证 i2i 真生效。"""
img = Image.new("RGB", (256, 256), (50, 100, 200))
# 中心红圆
from PIL import ImageDraw
draw = ImageDraw.Draw(img)
draw.ellipse((80, 80, 176, 176), fill=(220, 60, 60))
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
def main() -> int:
cfg = ArkConfig.load()
if cfg is None:
print("[SKIP] ARK_API_KEY 未设 / config/media/doubao.yaml 缺,无法测真接口")
return 0
image_cfg = (cfg.raw.get("image") or {})
if not image_cfg:
print("[SKIP] doubao.yaml 无 image 段")
return 0
variant_key, variant_cfg = next(iter(image_cfg.items()))
model_id = variant_cfg["model_id"]
print(f"[setup] model={model_id} endpoint=/images/generations")
png_bytes = make_test_png()
b64 = base64.b64encode(png_bytes).decode("ascii")
data_uri = f"data:image/png;base64,{b64}"
print(f"[setup] reference image: {len(png_bytes)} bytes -> data URI len={len(data_uri)}")
body = {
"model": model_id,
"prompt": "Keep the layout but change the red circle to a yellow star, blue background unchanged.",
"image_urls": [data_uri],
"size": "2048x2048", # ARK 最小要求 3686400 像素 (~1920²),1024² 会被拒
"response_format": "url",
"watermark": False,
}
print("[probe] POST /images/generations with image_urls=[data:image/png;base64,...]")
try:
with ArkClient(cfg, timeout_s=120.0) as client:
resp = client.post_json("/images/generations", body, timeout_s=120.0)
except ArkError as e:
msg = str(e)
print(f"[ArkError] {msg}")
low = msg.lower()
if "base64" in low or "image_url" in low or "invalid_url" in low or "url" in low:
print("\n[RESULT] base64-rejected (error mentions image_url/base64/url)")
print(" -> i2i 需要走公网 URL(对象存储)或 ARK file 上传 endpoint(待查)")
else:
print("\n[RESULT] unclear (ArkError but not obviously a base64 format issue)")
return 2
print(f"[response keys] {list(resp.keys())}")
snippet = json.dumps(resp, ensure_ascii=False)[:600]
print(f"[response (first 600ch)]\n{snippet}")
# 解析出图 URL
data = resp.get("data")
new_url = None
if isinstance(data, list) and data:
first = data[0]
if isinstance(first, dict):
new_url = first.get("url") or first.get("image_url")
elif isinstance(data, dict):
imgs = data.get("images")
if isinstance(imgs, list) and imgs and isinstance(imgs[0], dict):
new_url = imgs[0].get("url")
if new_url:
print(f"\n[RESULT] base64-supported (got new image url: {new_url[:90]}...)")
print(f" estimated cost ~{variant_cfg.get('price_cny_per_image', 0.22)} CNY")
return 0
print("\n[RESULT] unclear (HTTP 200 but no image url in response — check JSON above)")
return 1
if __name__ == "__main__":
sys.exit(main())