11

多模型协作命令行 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# config.py 或 llm.py
MODEL_CAPS = {
# 是否支持 temperature、token 上限用哪个字段、是否支持 json_object
"glm": {"temperature": True, "token_param": "max_tokens", "json_mode": False},
"deepseek": {"temperature": True, "token_param": "max_tokens", "json_mode": True},
"openai": {"temperature": False, "token_param": "max_completion_tokens", "json_mode": True},
}

def build_kwargs(provider, *, temperature=None, max_tokens=None, tools=None, json_mode=False):
caps = MODEL_CAPS[provider]
kw = {}
if max_tokens is not None:
kw[caps["token_param"]] = max_tokens
if temperature is not None and caps["temperature"]:
kw["temperature"] = temperature
if json_mode and caps["json_mode"]:
kw["response_format"] = {"type": "json_object"}
if tools:
kw["tools"] = tools
return kw

这一步应该第一个写,不是最后一个补。

1.2 智谱 web_search 不是 openai SDK 能调的接口

设计稿写「web_search 也走智谱,复用同一个 key」——复用 key 没问题,但它和 chat 是两条完全不同的路。官方 OpenAPI 描述的是独立 REST 端点:

1
2
POST https://open.bigmodel.cn/api/paas/v4/web_search
Authorization: Bearer <ZHIPU_API_KEY>

出处:智谱网络搜索 API 文档

必踩的坑(逐条对着 OpenAPI schema 抄下来的):

  1. search_engine 和 search_intent 都是 required——只发 search_query 直接 400。search_engine 取值:search_std / search_pro / search_pro_sogou / search_pro_quark。
  2. search_query maxLength = 70——模型很爱生成一百多字的中文查询,必须程序侧截断,否则 400。
  3. count 范围 1–50,默认 10。
  4. 错误码要单独处理:1701 搜索并发已达上限、1702 无可用搜索引擎、1703 引擎未返回有效数据 → 都应重试而不是抛异常中断。
  5. 正文在 search_result[].content,不是 snippet;另有 title / link / media / publish_date。
  6. content_size 选 medium(摘要)或 high(长正文),search_recency_filter 可限定 oneDay/oneWeek/oneMonth/oneYear——调研类任务建议默认开 oneYear 增强时效性。

修法:llm.py 里给搜索单开一个 HTTP 分支(httpx/requests,别硬塞进 openai SDK)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def web_search(query: str, *, count: int = 10, engine: str = "search_pro",
recency: str = "noLimit", content_size: str = "medium") -> list[dict]:
q = query[:70] # 硬截断,否则 70 字符上限直接 400
payload = {
"search_query": q,
"search_engine": engine, # required
"search_intent": False, # required
"count": max(1, min(int(count), 50)),
"content_size": content_size,
}
if recency != "noLimit":
payload["search_recency_filter"] = recency
for attempt in range(3):
r = httpx.post(SEARCH_URL, json=payload,
headers={"Authorization": f"Bearer {ZHIPU_API_KEY}"}, timeout=30)
if r.status_code == 200:
return r.json().get("search_result") or []
code = (r.json().get("error") or {}).get("code")
if code not in {"1701", "1702", "1703"}: # 非可重试错误直接返回
return [{"error": f"{code}: {r.text[:200]}"}]
time.sleep(1.5 * (attempt + 1))
return [{"error": "web_search failed after 3 attempts"}]

1.3 DeepSeek JSON 模式有「官方免责声明」

DeepSeek 官方 JSON Output 文档明确写了两条设计稿没接住的要求:

  1. Include the word “json” in the system or user prompt…
  2. 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
2
3
4
5
尝试解析:
1. 空 content → 重试(最多 3 次,逐次提高 max_tokens)
2. 截断/非法 JSON → 尝试补齐尾部括号 → 仍失败则正则提取 verdict/issues
3. 仍未拿到 verdict → 降级为关键词提取(PASS/FAIL 关键词)
4. 完全失败 → 默认放行(fail-open)+ transcript 打醒目警告

并且必须在文档里显式写明「审查器故障时 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
2
# 护栏 1 精心守护的沙箱,被护栏 2 一条命令绕过
run_command('python -c "import shutil; shutil.rmtree(\'C:/Users/...\')"')

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
2
C:\ws        ← 工作目录
C:\ws-evil ← startswith("C:\\ws") == True,逃逸成功

还要一并处理(你在 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
2
3
4
5
6
7
8
def safe_path(root: Path, user_path: str, *, for_write: bool = False) -> Path:
root = root.resolve()
p = (root / user_path).resolve() if not Path(user_path).is_absolute() else Path(user_path).resolve()
if for_write and not p.exists():
p = p.parent.resolve() / p.name # 父目录已解析,防止 symlink 逃逸
if os.path.commonpath([str(root).lower(), str(p).lower()]) != str(root).lower():
raise PermissionError(f"路径越界:{user_path}")
return p

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 GUI
  • http://192.168.x.x:6379 —— 内网服务探测
  • file:///C:/Users/.../.env —— 本地文件读取

而且抓回来的内容还会进入上下文。这个 agent 就跑在你本机,风险是实打实的。

修法(缺一不可):

1
2
3
4
5
6
7
8
9
10
11
12
13
ALLOWED_SCHEMES = {"http", "https"}

def check_url(url: str) -> None:
u = urlparse(url)
if u.scheme.lower() not in ALLOWED_SCHEMES: # 干掉 file:// ftp:// gopher://
raise PermissionError("仅允许 http/https")
for family, _, _, _, sockaddr in socket.getaddrinfo(u.hostname, u.port or 80):
ip = ipaddress.ip_address(sockaddr[0])
if (ip.is_private or ip.is_loopback or ip.is_link_local
or ip.is_reserved or ip.is_multicast or ip.is_unspecified):
raise PermissionError(f"拒绝访问内网/本机地址:{ip}")
# 另需:禁止跟随重定向到私网(手动处理 30x,每跳都校验)
# 另需:响应体大小上限(如 2MB)、content-type 白名单(text/html、text/plain、application/json)

注意: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 说完成就完成了。

两种情况任选一种(推荐都做):

  1. 加轻量第四阶段:把产物清单 + 关键命令输出喂给便宜模型,对照验收标准逐条判定 PASS/FAIL;
  2. 强制 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
2
3
4
5
6
7
8
{
"verdict": "PASS" | "FAIL",
"issues": [
{"severity": "blocker" | "major" | "minor",
"location": "计划中的原文片段",
"problem": "...", "suggestion": "..."}
]
}

只有 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
2
3
4
5
6
7
8
9
10
11
@dataclass
class ToolResult:
ok: bool
content: str # 回传给模型的可读文本
meta: dict | None = None # 耗时/截断标记/退出码等,供 transcript 使用

async def write_file(args: dict) -> ToolResult: ...

TOOLS = {
"write_file": {"schema": {...}, "func": write_file},
}

循环里统一 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. 待你决策的开放问题

  1. 是否坚持 gpt-5 做实施者? reasoning 模型跑 15–20 轮工具循环,延迟和成本都偏高。若目标只是「能跑通且便宜」,gpt-5-mini 或「GLM 做实施、GPT 做验收」可能更划算。建议先按设计稿实现,跑一次真实任务后再看账单决定。
  2. --yes 是否豁免污点确认? 建议不豁免(见 2.5),但如果你要无人值守跑,就得接受这个风险——需要你明确。
  3. 是否现在就做第四阶段验收? 不做的话,「验收标准」形同虚设(见 3.1);做的话多一次 API 调用。
  4. read_pdf 是否进 v1? 你的工作目录全是财报 PDF,调研类任务大概率第一次就会碰到。若不进 v1,read_file 至少要能明确报错而不是崩。
  5. 命令白名单里 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 的设计因此是对的。