多模型协作命令行 Agent —— 设计评审
评审对象:《多模型协作命令行 Agent:GLM 规划 → DeepSeek 审查 → GPT 实施》设计稿
结论一句话:骨架不用改,但第 5 节「七个内置工具」和第 6 节「四道安全护栏」需要按本评审重写一遍。
当前护栏写法会给人「已经安全了」的错觉,而实际上python白名单 + 可能的shell=True+ 无 SSRF 防护三者叠加,等于没有边界。
0. 总评
值得保留的设计
| 设计点 | 为什么对 |
|---|---|
工具注册表 TOOLS = {name: {schema, func}} |
加工具 = 加条目,能力边界清晰,是整份方案最好的决策 |
| 三阶段接力(规划 / 审查 / 实施) | 把「想清楚」和「动手」分开,对长任务确实有效 |
finish 作为显式结束工具 |
比「让模型输出特定结束文本」可靠得多 |
| 审查循环带轮数上限后放行进实施 | 避免死循环,工程上是正确的取舍 |
| transcript 全量落盘 | 出问题可复盘,调提示词的唯一依据 |
模型名走 .env 可覆盖 |
换模型不改代码 |
需要重写的部分
| 位置 | 问题定性 |
|---|---|
| 第 6 节 护栏 1(文件沙箱) | 实现细节有 Windows 正确性缺陷,且被护栏 2 抵消 |
| 第 6 节 护栏 2(命令白名单) | 设计矛盾:放行 python 等于放弃沙箱 |
第 5 节 web_search |
接口形态描述不准确(不是 SDK 调用) |
第 5 节 web_fetch |
缺 SSRF 防护,可被诱导读内网/本机服务 |
第 1 节 llm.py 统一封装 |
被 gpt-5 参数约束打穿,需参数适配层 |
| 第 3 节 审查循环 | 缺严重度分级,必然耗满 3 轮 |
| 流程整体 | 没人审「实现」,GLM 定的验收标准是死的 |
| MCP 演进说明 | 过于乐观,低估了异步与生命周期成本 |
1. 阻断级问题(P0:不修就跑不通)
1.1 统一 chat() 会被 gpt-5 的参数约束打穿
设计稿说「用 openai SDK 一套代码调三家」。但 gpt-5 系列不支持 max_tokens,也不接受 temperature != 1(传了直接 400)。
参考:litellm 针对该问题的修复 PR、GPT-5 新参数与工具 cookbook。
如果 llm.py 里写死 temperature=0.2, max_tokens=4096 三家共用,第三阶段第一次调用就炸。
修法:加一层模型能力表,调用前按能力裁剪参数。
1 | # config.py 或 llm.py |
这一步应该第一个写,不是最后一个补。
1.2 智谱 web_search 不是 openai SDK 能调的接口
设计稿写「web_search 也走智谱,复用同一个 key」——复用 key 没问题,但它和 chat 是两条完全不同的路。官方 OpenAPI 描述的是独立 REST 端点:
1 | POST https://open.bigmodel.cn/api/paas/v4/web_search |
必踩的坑(逐条对着 OpenAPI schema 抄下来的):
search_engine和search_intent都是 required——只发search_query直接 400。search_engine取值:search_std/search_pro/search_pro_sogou/search_pro_quark。search_querymaxLength = 70——模型很爱生成一百多字的中文查询,必须程序侧截断,否则 400。count范围 1–50,默认 10。- 错误码要单独处理:
1701搜索并发已达上限、1702无可用搜索引擎、1703引擎未返回有效数据 → 都应重试而不是抛异常中断。 - 正文在
search_result[].content,不是snippet;另有title/link/media/publish_date。 content_size选medium(摘要)或high(长正文),search_recency_filter可限定oneDay/oneWeek/oneMonth/oneYear——调研类任务建议默认开oneYear增强时效性。
修法:llm.py 里给搜索单开一个 HTTP 分支(httpx/requests,别硬塞进 openai SDK)。
1 | def web_search(query: str, *, count: int = 10, engine: str = "search_pro", |
1.3 DeepSeek JSON 模式有「官方免责声明」
DeepSeek 官方 JSON Output 文档明确写了两条设计稿没接住的要求:
- Include the word “json” in the system or user prompt…
- When using the JSON Output feature, the API may occasionally return empty content. We are actively working on optimizing this issue.
含义:
- 提示词里必须出现 “json” 字样,否则报错;
- 返回空 content 是已知行为,不是异常;
- 还要「设置合理的
max_tokens防止 JSON 被中途截断」。
修法:把策略写死进代码(这是设计稿最大的逻辑漏洞之一——「解析失败降级为关键字提取」后面没有下文了):
1 | 尝试解析: |
并且必须在文档里显式写明「审查器故障时 fail-open 还是 fail-closed」。 建议 fail-open:审查器是辅助环节,不该阻塞整个任务。
1.4 缺「启动冒烟检查」
gpt-5 需要组织验证,key 很可能没权限;模型名(如 deepseek-chat)也可能随时间变动——官方文档示例里已经出现 deepseek-flash 这类新名字。现在是跑到第三阶段才 401,前两阶段的钱白烧。
修法:开跑前给三个模型各发一个 1-token 的最小请求(可用 max_tokens=1 且不传 tools),任何一家失败就立即退出并给出中文明确提示。约 30 秒换一次「快速失败」,非常值。
2. 安全护栏:第 6 节需要重写
2.1 python 自动放行 = 文件沙箱形同虚设(设计内部矛盾)
设计稿写「python / pytest / git status 等自动放行」。问题在于:
1 | # 护栏 1 精心守护的沙箱,被护栏 2 一条命令绕过 |
pytest 同理(会执行工作目录里的 conftest.py,即任意代码);git 的部分子命令也会触发 hooks。
修法:白名单分级。
| 级别 | 命令 | 策略 |
|---|---|---|
| 只读安全 | git status、git diff、ls/dir、cat 类 |
自动放行 |
| 受限执行 | python script.py(必须带已存在的 .py 路径参数) |
自动放行,但校验脚本路径在沙箱内 |
| 任意执行 | python -c、python -m、pip install、pytest、npm |
一律人工确认 |
| 其他 | 一切未列出的 | 确认,或直接拒绝 |
如果嫌麻烦,至少要在 README 里诚实写明:沙箱只防误写、不防恶意。但不能不说。
2.2 如果实现用 shell=True,整个白名单被一个 && 绕过
1 | git status && del /f /q * # 第一个 token 是白名单,shell=True 下全执行 |
修法:
- 必须
shell=False+ 参数列表; - Windows 上别用默认 posix 模式的
shlex.split(会把C:\path的反斜杠吃掉),改用参数列表直传或shlex.split(cmd, posix=False); - 禁掉
|&;><`$(等元字符,或直接要求模型传数组参数。
这条比白名单内容本身更重要。
2.3 路径沙箱的 Windows 正确性缺陷
resolve() 之后用 str.startswith(workdir) 比较是错的:
1 | C:\ws ← 工作目录 |
还要一并处理(你在 D:\ 上跑,这些都真实存在):
| 坑 | 说明 |
|---|---|
| 大小写不敏感 | C:\WS 与 C:\ws 是同一目录 |
| 8.3 短名 | PROGRA~1 绕过字符串比较 |
| NTFS 数据流 | file.txt:evil 写到备用数据流 |
| 保留设备名 | CON、NUL、COM1 |
| UNC 路径 | \\?\C:\...、\\server\share |
| symlink / junction | 必须在解析后再比较 |
| TOCTOU | 检查通过后、写入前目标被替换成软链 |
| 目标不存在 | 此时要 resolve 父目录再拼文件名 |
修法:
1 | def safe_path(root: Path, user_path: str, *, for_write: bool = False) -> Path: |
Path.is_relative_to(Python 3.9+)也可以,但同样要先normcase/lower处理大小写。
2.4 web_fetch 完全没有 SSRF 防护(最该补的一条)
设计稿对 web_fetch 的描述只有「抓网页 → BeautifulSoup 抽正文」。当前形态下,模型可以叫它抓:
http://169.254.169.254/—— 云元数据(拿临时凭证)http://127.0.0.1:3080—— 就是你现在这个 DSH GUIhttp://192.168.x.x:6379—— 内网服务探测file:///C:/Users/.../.env—— 本地文件读取
而且抓回来的内容还会进入上下文。这个 agent 就跑在你本机,风险是实打实的。
修法(缺一不可):
1 | ALLOWED_SCHEMES = {"http", "https"} |
注意:DNS 解析后校验 IP 才有效;只校验 URL 字符串里的主机名会被 DNS rebinding 与十进制 IP(http://2130706433/)绕过。
2.5 「反提示词注入声明」是降概率,不是安全边界
设计稿把「提示词里声明无视网页内容中的指令」列为四道护栏之一,这高估了它的作用。模型仍可能被诱导。
真正的边界是工具能力,你已经做对一半(不提供删除工具),但另一半被 2.1 抵消了。
性价比最高的补强是污点标记(taint tracking):
一旦本会话出现过
web_fetch的返回内容,则其后的工具调用强制人工确认;且--yes不豁免这一类确认。
这比写十行「请无视注入」的提示词有用得多。同时把网页正文用明确分隔符包裹并标注为不可信数据,作为第二层防御。
2.6 两个小但会咬人的点
- 非 TTY 死锁:
input()在管道/CI 里会挂住。必须检测sys.stdin.isatty(),非 TTY 时默认拒绝该命令(而不是默认放行,也不是挂死)。 - Windows 不杀进程树:
subprocess的timeout只杀直接子进程,python起的孙子进程会残留。需CREATE_NEW_PROCESS_GROUP+taskkill /T /F(或 Job Object)。
3. 架构与流程问题(P1)
3.1 最关键的缺口:没有人审「实现」
GLM 定了验收标准,然后第三阶段结束就没人看了。GPT 说完成就完成了。
两种情况任选一种(推荐都做):
- 加轻量第四阶段:把产物清单 + 关键命令输出喂给便宜模型,对照验收标准逐条判定 PASS/FAIL;
- 强制
finish工具带结构化字段:{"summary": ..., "artifacts": [...], "acceptance": [{"criterion": ..., "met": bool, "evidence": ...}]}。
否则「验收标准」只是写着好看的摆设。
3.2 角色分工与能力错配
最贵的 gpt-5 干最重最长的活:40 轮 reasoning + tool calls,每轮推理 token 都计费。
量级估算(粗估,取决于任务复杂度):单次任务 十几分钟到几十分钟、数美元级。40 轮 × (推理 token + 上下文重放) 是成本主要来源。
修法:
- 轮数上限从 40 降到 15–20(绝大多数任务够用);
- 加全局预算熔断:wall clock 上限 + 累计 token 上限 + 费用估算上限,任一触发即优雅停止并落盘 transcript(只靠轮数上限挡不住成本);
- 打印每阶段耗时与 token,让自己对花销有体感。
3.3 审查者的建议可能不可执行
DeepSeek 若只看到「任务描述 + 计划文本」,会给出能力边界之外的建议(「建议引入 X 库」「建议用 MCP」),而实施者只有 7 个工具。
修法:reviewer 的 prompt 必须注入:
- 7 个工具的能力清单(含参数约束,如「无网络写权限」「无删除工具」「命令受白名单限制」);
- 工作目录真实文件列表;
- 明确约束:「不得提出超出上述工具能力范围的建议;每条 issue 必须指向计划中的原文片段」。
顺带说一句:审查质量主要来自给它可验证的事实,而不是换个模型。DeepSeek 审 GLM 计划,两家能力相近,靠「异源」防放水的效果有限。
3.4 审查 issue 缺严重度分级 → 必然耗满 3 轮
{"verdict","issues"} 把致命问题和措辞建议混在一起,前两轮很容易耗在细节上。
修法:
1 | { |
只有 blocker 才阻止进入实施;major/minor 记录后随计划一起下发,让实施者自行取舍。这样 3 轮上限才花在刀刃上。
3.5 没定义「实施阶段发现计划错了」怎么办
GPT 只有两个坏选择:硬着头皮实现错计划,或自作主张乱改。
修法:加一个 request_replan(reason) 工具(或允许它在输出里显式报告偏离),触发一次回到 GLM 的修订(计入总预算,最多 1 次)。
3.6 工具循环的防呆一个都没提
| 缺什么 | 后果 | 修法 |
|---|---|---|
| 重复调用检测 | 模型陷入「写同一文件 → 读 → 再写」死循环 | 同 args 连续 N(=3) 次即注入提醒,再犯则中止 |
模型不调 finish 只回纯文本 |
循环不知道怎么办 | 视为结束(或提醒一次后结束),并落盘该文本为结论 |
| 工具异常 | 程序直接崩 | 结构化回传 {"ok": false, "error": ...},让模型自己纠错 |
| 上下文膨胀 | 40 轮后撑爆 token 上限 | 历史裁剪/摘要:保留 system + 计划 + 最近 N 轮 + 工具结果摘要 |
| 工具结果过大 | 同上 | 单条结果硬截断(如 8KB)+ 标注「已截断」 |
3.7 建议加 --plan-only
调提示词时每次都烧三个阶段的钱非常痛苦。这个开关几乎零成本,收益极高。
4. MCP 演进说明需要修正
设计稿:「将来加一个从 MCP Server 拉取 schema 并转发的加载器,注册表和循环逻辑不变」——过于乐观。
MCP 的真实成本:
| 项 | 说明 |
|---|---|
| 异步 | JSON-RPC over stdio / Streamable HTTP,func 得是 async |
| 生命周期 | initialize → tools/list → tools/call,还有 notifications |
| 进程管理 | stdio 型 MCP Server 要管理子进程启停、崩溃重启、超时 |
| 命名冲突 | 多个 Server 的工具名要加命名空间前缀 |
| Schema 转换 | MCP 的 inputSchema 与 OpenAI function 格式不完全一致,要写转换器 |
| 鉴权与配额 | 每个 Server 一套凭据、限流、超时 |
但现在有一个「花 10 分钟、将来省一天」的准备动作——把执行器签名直接定成异步 + 结构化返回:
1 |
|
循环里统一 await,内部实现该同步就同步(用 asyncio.to_thread 包一下即可)。这样将来接 MCP 只是多一个注册来源,循环一行不用改。
这是整份方案里性价比最高的一个改动建议。
5. 工程细节清单(零散但会咬人)
| # | 项 | 问题 | 修法 |
|---|---|---|---|
| 1 | 编码 | Windows 上默认 GBK,写代码含中文/emoji 会崩 | 所有 open() 显式 encoding="utf-8" |
| 2 | 子进程解码 | 控制台输出非 UTF-8 时 UnicodeDecodeError 直接崩程序 |
decode(errors="replace") |
| 3 | read_file 二进制防护 |
工作目录里全是 PDF,读 PDF 必抛异常 | 先嗅探二进制(\x00 检测),明确报错;v2 加 read_pdf(调研类任务刚需) |
| 4 | transcript 脱敏 | 命令输出/网页正文可能含 API key | 落盘前正则打码 sk-、Bearer 后的串;runs/ 进 .gitignore |
| 5 | transcript 时间戳 | 只到秒,并发运行会撞 | 用 %Y%m%d-%H%M%S + 短随机后缀 |
| 6 | write_file 原子性 |
中途失败留下半截文件 | 写临时文件 + os.replace |
| 7 | 覆盖策略 | 没定义,重跑行为不确定 | 默认允许覆盖但记录 overwrote: true;或不含 --force 时拒绝覆盖已存在文件 |
| 8 | key 校验 | 缺 key 要跑到第一次调用才 401 | 启动时校验,缺哪个报哪个(中文) |
| 9 | 文件账 | 说「约 7 个源文件」,实际列了 8 项(py 只有 5 个) | 建议 agent.py 拆出 executor.py(工具循环),py 变 6 个 |
| 10 | agent.py 责任 |
CLI + 编排 + 两个循环 + 落盘,过于臃肿 | 内部至少严格分层:cli() / plan_stage() / review_loop() / execute_loop() / write_transcript() |
| 11 | 缺目录约定 | runs/、workspace/、.gitignore 都没提 |
启动时自动创建 |
| 12 | 缺测试 | 沙箱/白名单/JSON 降级都是纯函数,却没测试 | 加 tests/,沙箱必须覆盖逃逸用例(../、绝对路径、symlink、大小写、..\\、C:\ws-evil) |
| 13 | 调研任务不可复现 | 联网结果的回归测试等于掷骰子 | 把 web_search/web_fetch 打桩(fixture)后做确定性测试 |
| 14 | 缺自动验收 | 「猜数字游戏」怎么算通过? | 给示例任务配验收脚本(echo "50\n" | python game.py 断言输出) |
6. 修订后的实施顺序
原设计稿的 7 步顺序需要调整——先把「容易炸的地基」写好,再写编排。
| 步 | 内容 | 完成判据(可验证) |
|---|---|---|
| 1 | requirements.txt / .env.example / config.py |
缺 key 时报中文明确错误;MODEL_CAPS 能力表就位 |
| 2 | llm.py 参数适配层 + 三客户端 + 冒烟检查 |
三家各发一次最小请求全部 200 |
| 3 | llm.py 的 web_search(独立 HTTP 分支) |
真实调用返回 ≥1 条结果;query 超 70 字符被正确截断 |
| 4 | tools.py 骨架:注册表 + ToolResult + 异步签名 |
注册表可枚举、schema 可 dump |
| 5 | 路径沙箱 + 命令白名单分级 + shell=False |
通过全部逃逸单测(含 C:\ws-evil、symlink、大小写) |
| 6 | web_fetch + SSRF 校验 |
拒绝 127.0.0.1 / 169.254.169.254 / file://;正常网页可抓 |
| 7 | prompts.py 三角色(含工具边界声明 + 反注入) |
reviewer 输出的 issue 均在工具能力范围内 |
| 8 | agent.py 三阶段编排 + 审查循环(issue 分级) |
--plan-only 跑通,打印计划与分级意见 |
| 9 | 工具循环 executor.py + 重复检测 + 上下文裁剪 + 预算熔断 |
小任务能在 10 轮内 finish,超预算时优雅停止 |
| 10 | transcript 落盘 + 脱敏 | 落盘文件里搜不到 key |
| 11 | 端到端:编码类 + 调研类 | 编码类有自动验收脚本;调研类用 mock 复跑 |
| 12 | 按实跑结果调提示词 | 3 轮审查内通过率、平均轮数有记录 |
与原文最大的差异:把「护栏」和「参数适配」提到编排之前(原文是第 4、5 步才做工具,第 6 步才测试),避免返工。
7. 待你决策的开放问题
- 是否坚持 gpt-5 做实施者? reasoning 模型跑 15–20 轮工具循环,延迟和成本都偏高。若目标只是「能跑通且便宜」,
gpt-5-mini或「GLM 做实施、GPT 做验收」可能更划算。建议先按设计稿实现,跑一次真实任务后再看账单决定。 --yes是否豁免污点确认? 建议不豁免(见 2.5),但如果你要无人值守跑,就得接受这个风险——需要你明确。- 是否现在就做第四阶段验收? 不做的话,「验收标准」形同虚设(见 3.1);做的话多一次 API 调用。
read_pdf是否进 v1? 你的工作目录全是财报 PDF,调研类任务大概率第一次就会碰到。若不进 v1,read_file至少要能明确报错而不是崩。- 命令白名单里
python的最终等级:完全自动放行(方便但沙箱失效)/需确认(安全但打断流程)/仅允许「带沙箱内.py路径」的形式(折中,推荐)。
8. 附录:已核实的外部事实与出处
| 事实 | 出处 |
|---|---|
智谱网络搜索是独立端点 POST /paas/v4/web_search;search_engine、search_intent 必填;search_query ≤70 字符;错误码 1701/1702/1703 |
docs.bigmodel.cn 网络搜索 |
| DeepSeek JSON Output:prompt 必须含 “json” 字样;可能偶发返回空 content;需合理设置 max_tokens 防截断 | api-docs.deepseek.com/guides/json_mode |
gpt-5 系列不支持 max_tokens,且不接受 temperature != 1 |
litellm PR #13390、GPT-5 参数与工具 |
GLM base_url https://open.bigmodel.cn/api/paas/v4、glm-4.6 可用 |
GLM-4.6 API |
注:
deepseek-chat的可用性请在步骤 2 的冒烟检查里实测确认(官方文档示例中已出现deepseek-flash等新模型名,模型线会变),模型名走.env的设计因此是对的。