Python · asyncio · LLM-Bridge

子进程的三条不变量——别把 CLI 跑成僵尸

LLM-Bridge 复盘:网关的活儿本质是「安全地 shell out 别人的 CLI」。stdin、stderr、finally kill 三条不变量,少一条就漏进程或死锁。

LLM-Bridge 里 codex 和 agy 两个后端,本质都是一件事:把一个请求变成一次 codex exec / agy -p - 的子进程调用,再把它的输出转回 OpenAI 格式。

听起来是 asyncio.create_subprocess_exec 一把梭。真正难的不是启动,是收尾——一个高并发、客户端随时可能断开的网关里,稍不留神就会漏出僵尸进程,或者把子进程锁死。这篇是三条踩出来的不变量,编辑这两个 provider 时一条都不能破。

不变量一:prompt 走 stdin,不走 argv

第一直觉是把 prompt 拼进命令行参数:codex exec -m gpt-5.5 "整段对话……"。这在一句话的时候没问题,在真实聊天里会炸——多轮对话拼成的 prompt 轻松几万字符,直接撞上操作系统的参数长度上限ARG_MAX),子进程还没跑就 E2BIG 挂了。

所以 prompt 一律走 stdin:

args = [self.cli_path, "exec", "--json", "--skip-git-repo-check",
        "--ephemeral", "-m", model, "-"]  # 末尾的 "-" 表示从 stdin 读
proc = await asyncio.create_subprocess_exec(
    *args,
    stdin=asyncio.subprocess.PIPE,
    stdout=asyncio.subprocess.PIPE,
    stderr=...,
)
proc.stdin.write(prompt.encode())
await proc.stdin.drain()
proc.stdin.close()

命令行参数只放固定的开关,变长的内容全部从管道喂进去。drain() 之后 close(),让子进程读到 EOF 知道输入结束。参数长度是有上限的,管道没有——凡是长度不可控的东西,都别塞进 argv。

不变量二:不 drain 的 stderr 必须是 DEVNULL

这是最隐蔽的一条,会以「偶发卡死」的形式出现。

流式路径为了低延迟,是一边读 stdout 一边往外吐,从不去读 stderr。这时候如果 stderr 接的是 PIPE,隐患就埋下了:操作系统的管道缓冲区是有限的(通常 64KB),子进程往 stderr 写日志写满了缓冲区,而没人来读,它就会阻塞在写 stderr 这一步——stdout 也不再产出,整条流卡死。你以为模型在思考,其实子进程正卡在一个没人读的管道上。

解法是按两条路分开处理 stderr:

async def _run_cli(self, prompt, model, capture_stderr):
    ...
    stderr=asyncio.subprocess.PIPE if capture_stderr else asyncio.subprocess.DEVNULL,
  • 流式路径capture_stderr=False):stderr 直接丢进 DEVNULL,永远不会写满,也就不会死锁;
  • 非流式路径capture_stderr=True):用 communicate(),它会同时 drain stdout 和 stderr,所以可以安全地用 PIPE 把 stderr 捕获下来,拿去拼错误信息。

一句话记:只要你不打算读某个管道,就把它设成 DEVNULL;一个没人读又写得满的 PIPE,就是一个定时死锁。

不变量三:finally 里 kill,客户端一断就清理

网关是 SSE 流式响应,客户端(浏览器标签、断网、用户点了停止)随时可能中途断开。这时候 FastAPI 会去 aclose() 那个异步生成器,Python 会在当前 yield 那一行抛一个 GeneratorExit

如果你只在「正常读完」的路径上清理子进程,那客户端半路断开时,codex / agy 进程还在那儿跑——烧着 CPU、占着你的订阅配额、变成一个孤儿进程。跑一天下来,ps 一看几十个僵尸。

解法是把清理放进 finally,让它对正常结束、异常、客户端断开三种情况一视同仁:

async with self._semaphore:
    proc = await self._run_cli(prompt, model, capture_stderr=False)
    try:
        yield make_role_chunk(state)
        async for raw_line in proc.stdout:
            ...  # 解析、转成 chunk 吐出去
    finally:
        # 正常退出、报错、客户端断开(GeneratorExit)都会走到这
        # 绝不留下一个孤儿 CLI 进程
        if proc.returncode is None:
            proc.kill()
            await proc.wait()

GeneratorExit 会触发 finallyfinally 检查进程是否还活着(returncode is None),活着就 kill()wait() 收尸。wait() 不能省——不 wait 就变成 defunct 僵尸,进程表里照样占位。非流式路径同理:communicate() 超时后也是 proc.kill() + await proc.wait()

SDK 那边:用 aclosing 把断开传下去

claude 走的是官方 Agent SDK,进程生命周期由 SDK 自己管——但前提是你得把「客户端断开」这个信号传给它。做法是用 aclosing 包住 SDK 的 query()

from contextlib import aclosing

async with aclosing(query(prompt=prompt, options=options)) as messages:
    async for message in messages:
        ...

aclosing 保证生成器退出时(包括客户端断开触发的 GeneratorExit)调用它的 aclose(),这个 close 会传播进 SDK,SDK 再去拆掉它自己启动的 CLI 进程。自己管子进程要手写 finally kill,用 SDK 就换成 aclosing——机制不同,但要守的东西是同一个:没有任何一条退出路径可以漏掉进程清理。

小结

网关的难点不在启动子进程,在于在一个客户端随时断开的环境里安全地收尾:

  • prompt 走 stdin:argv 有长度上限,管道没有——长度不可控的内容别进命令行参数;
  • 不读的 stderr 设 DEVNULL:没人读又写得满的 PIPE 会让子进程阻塞在写 stderr 上,整条流静默死锁;
  • finally 里 kill + waitGeneratorExit 会在客户端断开时抛出,finally 统一清理,wait() 收尸防僵尸;
  • SDK 用 aclosing:把生成器 close 传播给 SDK,让它拆自己的进程。

一句话:把「别人的 CLI」跑成一个可靠的后端,代码量不在跑通,而在于把每一条退出路径都堵上。

留言

  • 加载中…

留言先审后发,通过后公开显示;邮箱只有站主可见。