TL;DR:Codex CLI 是给 GPT-5 设计的编码 agent,但它本质上只是往一个 OpenAI Responses 端点发请求。理论上,你把这个端点换成自己用 vLLM 部署的 GLM-5.2 就能白嫖一个顶级开源编码模型。理论上。 实际上中间隔着五个不明显、但每一个都能让整个会话卡死的坑。openafw 把这五个坑全填了,现在一个
--tool-call-parser glm开关就能跑通。
为什么想这么干
GLM-5.2 是目前最强的开源编码模型之一,自己用 vLLM 部署一份,配上 Codex 那套成熟的 agent 框架(计划、工具、diff 应用),等于用开源栈拼出一个不输商业产品的编码 agent。而且数据不出本地、成本可控。
Codex CLI 连模型的方式很简单——它读 model_providers.<name>.base_url,然后往 <base_url>/responses 发 OpenAI Responses API 请求。所以第一直觉是:
# ~/.codex/config.toml
[model_providers.mine]
base_url = "https://my-vllm-host/coding/v1"
vLLM 也确实支持 Responses API,GLM 也确实能被 --tool-call-parser glm47 --reasoning-parser glm45 拉起来。看起来就是改个 URL 的事。
然后你会发现:模型一调用工具,整个会话就停下来了。
下面把这五个坑逐个拆开——既是想说清楚原理,也是想说明:这些坑你自己都能填,但每一个都要花几个小时读源码、抓包、对比 trace。openafw 已经替你填完了。
坑 1:同协议直连,恰恰是问题所在
Codex 说 Responses API,你的 vLLM 也说 Responses API。大多数网关(包括 openafw 早期)在「源协议 == 目标协议」时会走一条零拷贝直通路径:请求和响应字节级透传,不解析、不翻译,最快。
问题是——正因为不解析,模型返回的东西里如果有需要「翻译」的内容,就直接漏给了 Codex。而 GLM 返回的工具调用,恰恰需要翻译(见坑 2)。
openafw 的处理:给 provider 加一个 toolCallParser 标记(下面会讲),一旦标记,这条路由就不走直通,改走缓冲路径——把整个响应读完、解析、修好,再交给 Codex。这也是为什么这个能力需要一个显式开关:是否需要介入,是端点的属性,不能凭空猜。
坑 2:GLM 的工具调用「方言」泄漏成了普通文本
这是最核心的一个。
GLM 系列模型的原生工具调用格式,不是 OpenAI 那种结构化的 tool_calls 字段,而是内联在文本里的一段 XML:
<tool_call>apply_patch<arg_key>definition</arg_key><arg_value>*** Begin Patch
*** Add File: hello.txt
+hello world
*** End Patch
</arg_value></tool_call>
vLLM 的 --tool-call-parser glm47 就是负责把这段文本解析成结构化 tool_calls 的。但有个关键细节:这个 parser 是挂在 /v1/chat/completions 上的,配合 --enable-auto-tool-choice 生效(官方 GLM-5 recipe 里的例子清一色是 chat completions)。而 Codex 走的是 /coding/v1/responses——Responses API。在 Responses API + Codex 的 custom(freeform)工具这个组合下,vLLM 根本没跑这个 parser,于是那段 <tool_call>... 原封不动地当作 output_text 吐了出来。
Codex 收到的是一条「普通助手文本消息」,里面是一坨它看不懂的 XML。它找不到任何工具调用,于是——停下来。这就是「一调用工具就卡死」的真相。trace 里能看到:整个 run 只有一次 model_call,输出是纯文本,没有 function_call。
openafw 的解法是自己实现一个和 vLLM glm47 行为对齐的解析器(读了 vLLM 的 Rust 源码 glm_xml/glm47_moe.rs 逐条对齐):
-
灵活的函数名解析(名字紧跟 <tool_call>之后,换行或空白分隔都支持); -
<arg_key>/<arg_value>成对解析; -
值类型转换( "42"→42、"true"→true、"[1,2,3]"→数组、""→空串); -
零参数调用 <tool_call>name</tool_call>→{}(vLLM 曾经在这里崩过,有对应的 issue); -
顺带兼容 Hermes/Qwen 的 JSON-in- <tool_call>和 Claude 的<invoke>两种方言。
解析出来后,把这段文本「提升」成一个真正的工具调用块,再往下走。
坑 3:apply_patch 是 freeform 工具,不能当普通函数
Codex 的 apply_patch 不是普通的 JSON 参数工具,而是一个 custom(freeform)工具:它的调用是一整段遵循 Lark 语法的纯文本补丁(*** Begin Patch ... *** End Patch),工具描述里甚至明写「This is a FREEFORM tool, so do not wrap the patch in JSON」。
这里有个很违反直觉的实验结果。我们试过三种把工具喂给 GLM 的方式:
|
|
|
|---|---|
原生 custom 工具
|
|
{input: string} 函数
|
|
{path, content} 结构
|
|
强模型反而喜欢原生的 custom 工具。 所以正确做法不是「翻译」,而是「保持」——让 custom 工具原样到达 GLM,只在响应侧把模型的调用还原成 custom_tool_call 回给 Codex(因为 Codex 注册的就是 custom 类型,你回一个 function_call 它不认)。
openafw 为此在 IR(中间表示)里保留了 freeform 标记和 grammar,响应侧对这些工具发 custom_tool_call 而不是 function_call;跨协议(比如路由到只支持 chat 的端点)时才降级成带 grammar 描述的单 input 函数。两条路都照顾到。
坑 4:vLLM 的 Responses API 会被自己的历史噎死
修好前三个之后,单轮通了。但第二轮请求一发,vLLM 直接 500:
File ".../vllm/entrypoints/openai/responses/utils.py", line 224,
in _construct_message_from_response_item
prev_msg if prev_msg and prev_msg.get("role") == "assistant" else None
AttributeError: 'ResponseCustomToolCall' object has no attribute 'get'
读 vLLM 源码可知:它的 Responses 输入重建函数只认识 function_call、function_call_output、reasoning、message 这几种历史项,对 custom_tool_call / custom_tool_call_output 没有任何分支,直接 return item(返回原始 pydantic 对象),下一轮迭代 prev_msg.get(...) 就炸了。
而这个 custom_tool_call,正是 openafw 在坑 3 里合成、发给 Codex 的——Codex 忠实地在下一轮历史里把它回传了回来。等于我们自己给 vLLM 埋的雷。
openafw 的解法:在把请求发给 toolCallParser 端点之前,把历史里的 custom_tool_call 降级成 function_call、custom_tool_call_output 降级成 function_call_output(这俩 vLLM 能正确处理)。工具定义本身仍保持 custom(GLM 直接调用效果最好),只改写回传的历史项。这是响应侧「合成 custom_tool_call」的请求侧对偶。
坑 5:最隐蔽的一个——工具调用 ID 全部撞车,模型陷入死循环
这个坑最阴。前四个都修好、多轮也不崩了,结果模型开始反复地 add README.md → delete README.md → add → delete,无限循环,还一本正经地说「让我先好好了解一下工作区」。
第一反应是「GLM 不行」。但扒开那条 236 条消息的会话历史,发现真相:110 个工具调用的 ID 全是同一个 afw_xml_0。
原因是 openafw 给内联工具调用合成 ID 时,用了一个每个响应内重置的计数器afw_xml_${index}。GLM 每轮只发一个调用,于是每轮都是 afw_xml_0。跨轮全撞在一起。
后果是灾难性的:发给 GLM 的历史里,100 多个 function_call 和 function_call_output 的 call_id 完全相同,模型根本分不清哪个结果对应哪次调用,彻底失去对自己行为的记忆,于是退化成机械地来回切换文件。
修复很简单——把合成 ID 改成全局唯一(afw_xml_<nanoid>),ID 会随 Codex 回传在各轮间保持一致,vLLM/GLM 就能正确配对了。但发现它花的时间远超修它的时间:你得先排除「模型能力问题」这个错误假设,一条条看 trace 的 tool id,才能定位到这个只有一行的 bug。
你完全可以自己做,但……
上面每一个坑,单独看都不难,修复也就几行到几十行代码。但问题在于:
-
坑 2 要你读懂 vLLM 的 Rust tool parser 源码,还要知道它只在 chat completions 生效; -
坑 3 要你做 A/B 实验才能反直觉地发现「别翻译、保持原样」; -
坑 4 要你去读 vLLM 的 Python Responses 实现才知道它不认 custom_tool_call; -
坑 5 要你怀疑自己、逐条比对 236 条历史里的 tool id 才能揪出来。
这是好几个晚上的抓包、读源码、对 trace。 而 openafw 已经把这一切压缩成了一个开关。
用 openafw,三步跑通
# 1. 注册你的 vLLM 端点,打上 glm 工具解析开关
afw model provider add mygpu \
--api openai-responses \
--base-url https://my-vllm-host/coding/v1 \
--auth bearer \
--tool-call-parser glm # ← afw 版的 --tool-call-parser glm47
# 2. 把 Codex 路由到这个模型(og-coding 是你在该 provider 下注册的模型 id)
afw route set 'codex/*' --model og-coding
# 3. 在项目目录里启动 Codex(afw 自动把它指向 wire,不改 Codex 的共享配置)
afw codex
--tool-call-parser glm 这个命名是刻意和 vLLM 的 --tool-call-parser glm47 对齐的——它就是 afw 侧的同一个概念:告诉网关「这个端点用文本内联的方式吐工具调用,请解析它」。老的 xmlToolCalls: true 布尔值仍然兼容,自动映射到 glm。
为什么值得用 openafw,而不是自己维护一份
-
开源、MIT、无遥测。 afw 是本地防火墙式的 agent 代理,零账号、零上报,你的代码和密钥只发往你 agent 本来就要调的那个上游。 -
有人维护。 上面这五个坑,是持续在真实 trace 上打磨出来的。GLM 会更新,vLLM 会更新,parser 格式会变——这些持续的适配有人跟。 -
遇到问题能直接改。 这才是开源真正的价值:如果你的部署撞上了第六个坑(几乎一定会有),你不用等谁——afw 的解析器、路由、翻译层都是清清楚楚的 TypeScript,fork 下来改一行,提个 PR,就贡献回了社区,下一个人就不用再踩。这五个坑的修复本身,就是这么一条条被发现、验证、合并进 @openafw/openafw的。
自托管开源模型的意义,是把控制权拿回自己手里。openafw 想做的,就是让「拿回控制权」这件事,不必从每次都重踩同样的五个坑开始。
openafw 是 OpenGuardrails 出品的开源项目 · npm i -g @openafw/openafw · GitHub: openafw/openafw · MIT License

