← blog 架构与系统 · 2026-09-16

Agent LLM Gateway:给 Codex CLI 加一层可追踪的本地透明网关

为你的 Agent 加上一个观察输入输出的网关,后续支持修改 prompt 和 Provider 路由

8 min read

当 Codex CLI 直接访问 OpenAI API 时,请求、流式响应、耗时和 token 使用情况都分散在客户端和上游之间。这个项目提供了一个运行在本机的透明网关:客户端只需要把 OPENAI_BASE_URL 指向 http://127.0.0.1:8787/v1,网关就会保留原始路径,把请求转发到 OpenAI,同时把一次调用的生命周期保存到本地 SQLite。

它的目标不是做一个新的 API 服务,而是尽量不改变 Codex 的行为,并为每次调用增加可查询的观测记录。网关不修改请求、不重试、不做模型路由,也不把 API key 写入数据库或 CLI 输出。对于一个本地单用户 MVP,这个边界让实现足够小,也让问题定位有明确的责任范围。

    sequenceDiagram
        participant U as 用户
        participant C as Codex CLI
        participant G as Gateway
        participant D as SQLite
        participant O as OpenAI API
        U->>C: 输入任务
        C->>G: POST /v1/responses
        G->>D: 创建 call 和 attempt
        G->>O: 透传 request
        O-->>G: JSON 或 SSE response
        G-->>C: 立即转发 response/chunk
        G->>D: 保存完整 request/response 与状态
        C-->>U: 展示模型输出
        U->>C: 继续操作
        C->>G: 下一次 request
        G->>D: 同一 session 下创建新的 call

功能边界:透明转发加本地观测

网关启动后绑定 127.0.0.1:8787,默认上游是 https://api.openai.com。它接收 POST /v1/...,拼接上游地址并保留原路径,例如 /v1/responses 会被转发到 OpenAI 的同一路径。配置可以通过 CLI 参数、AGENT_GATEWAY_* 环境变量或默认值提供,优先级依次降低。

请求进入后,处理器先读取 body,从 JSON 中提取 model 和 stream,并为当前 session 创建一条 call 以及一次 attempt。请求头经过 headers.sanitize 后才进入数据库;Authorization、Cookie、Host、长度和连接控制等字段不会被保存。转发到上游时,认证头仍然只在内存中透传,因此网关既能完成鉴权,又不会把密钥带进本地记录。

响应路径分为普通 JSON 和 SSE 两种情况,但采用同一套读取循环。每次从上游读取最多 8192 字节,立刻写入客户端并 flush(),同时复制一份内容用于持久化。这样客户端可以按上游节奏看到流式输出,数据库则在请求结束后得到完整的可查询快照。网关会记录 HTTP 状态码、响应头、首字节时间、完成时间、输入输出字节数和错误信息;如果 Content-Length 与实际收到的字节数不一致,会把调用标记为 upstream_incomplete。

请求和响应 body 默认各保存最多 10 MiB。超出上限时只保存前缀,并分别设置 request_truncated 或 response_truncated;response_complete 描述上游传输是否完整,和 HTTP 状态码是两个维度。SSE 的 usage 也能被提取:普通 JSON 从顶层 usage 读取,流式响应则逐行检查 data: 事件并忽略 [DONE]。

CLI 只保留必要的运维入口:init 初始化数据库,start 启动网关,list 查看最近调用,show <id> 查看一次调用的请求、响应、头部、状态和错误;usage 会按 attempt 单独保存,purge 清理两天以前的调用。启动和手工 purge 都会执行保留策略。

技术栈选型:为什么是 Python 标准库加 SQLite

项目的运行时依赖只有 Python 3.9+ 标准库;开发依赖只有 pytest。HTTP 层使用 http.server.ThreadingHTTPServer,上游请求使用 urllib.request,时间、JSON 和 SQLite 也直接使用标准库。对于本机单用户的透明代理,这个选择减少了安装步骤、线程模型和部署变量,源码也容易跟踪到每一个字节如何被转发和记录。

ThreadingHTTPServer 让多个本地请求可以并发处理,但系统仍保持单进程、单网关的模型。它不是为公网高并发设计的,因此没有引入异步框架、连接池、消息队列或独立写入服务。当前实现把数据库写入放在请求处理线程中;这牺牲了一部分极限吞吐,却让一次 attempt 的状态更新和 payload 写入可以在同一段清晰的生命周期内完成。

SQLite 适合这个项目的原因是数据量、访问范围和生命周期都很明确:数据只服务于本机调试,保存时间只有两天,查询主要是按调用时间和 ID 查看详情。它不需要数据库服务、账号管理或网络连接。项目还为 calls.created_at 建立索引,并用应用层按关联 ID 清理子记录,避免引入迁移工具和外键级联的复杂度。

数据库设计:把“调用”和“尝试”拆开

    erDiagram
        SESSIONS ||--o{ CALLS : "session_id"
        CALLS ||--|{ ATTEMPTS : "call_id"
        ATTEMPTS ||--|| PAYLOADS : "attempt_id"
        ATTEMPTS ||--o| USAGE : "attempt_id"
        ATTEMPTS ||--o{ STREAM_CHUNKS : "attempt_id"
        SESSIONS { integer id PK }
        CALLS { integer id PK integer session_id }
        ATTEMPTS { integer id PK integer call_id }
        PAYLOADS { integer id PK integer attempt_id }
        USAGE { integer id PK integer attempt_id }
        STREAM_CHUNKS { integer id PK integer attempt_id }

图中的关系是业务关系;当前 SQLite schema 没有创建外键约束,而是由应用层负责写入、查询和清理。

sessions 表代表一次 Codex CLI 运行,保存 agent、启动时间、最后活动时间、工作目录和元数据。一个 session 可以产生多个 calls,每次客户端请求对应一条 call,包含 endpoint、model、stream、整体状态、状态码、耗时以及总输入输出字节数。attempts 是 call 到上游的一次实际尝试,保存上游 URL、请求/响应头、首字节时间和错误。当前 MVP 每个 call 创建一次 attempt,但单独建表为后续重试或故障转移留下了位置,也避免把“用户发起的调用”和“网络传输尝试”混成一个概念。

大字段放在 payloads,并以 attempt_id UNIQUE 保证一次尝试只有一份请求/响应快照。这样查询列表时不必携带 prompt 和完整输出,show 时再按需关联。截断标记、内容类型、更新时间和完整性标记也跟 payload 放在一起,避免把传输状态塞进二进制字段本身。

usage 与 attempt 一对一,保存输入、输出、总 token 和原始 usage JSON。保留原始 JSON 是为了兼容 Responses API 与旧式字段名(prompt_tokens、completion_tokens),也便于未来增加供应商字段。stream_chunks 在 ER 图中作为扩展点存在,但当前实现不逐 chunk 入库:逐块写 SQLite 会放大 IO,并可能影响 SSE 的实时性;MVP 只在内存中累积受限大小的响应快照。

这种拆分的取舍是表和更新步骤更多,但生命周期更准确。创建阶段先写 session、call、attempt、payload;转发完成后再回填状态、响应头、计时、完整 body 和 usage。清理时按 call 找到 attempts,再删除 usage、payload、attempt 和 call,保证过期数据不会留下孤立的大字段。

项目架构:薄 CLI、核心处理器和可替换的外围模块

代码位于 agent_gateway_pkg/。config.py 定义默认数据库、监听地址、上游地址和 body 上限;cli.py、server.py、proxy.py 提供命令入口、服务器构造和 Handler 导出;实际请求生命周期集中在 core.py。这种组织保留了标准库 MVP 的直接性,同时给后续拆分保留了边界。

一次请求的处理可以概括为四个阶段:

  1. 接收与建档:读取 Content-Length 指定的 body,提取模型和流式标记,更新 session 的 last_seen_at,创建 call、attempt 和 payload 初始记录。
  2. 安全转发:构造上游 URL,过滤 Host 等不应转发的连接字段;Authorization 只参与上游请求,不进入持久化头部。上游 HTTP 错误仍作为响应转回客户端,并记录错误状态。
  3. 边读边回写:读取上游 chunk,记录首字节时间,立即写回客户端并 flush,同时按 10 MiB 上限保存响应前缀。客户端断开或上游异常时,保留失败类型和错误消息。
  4. 完成与归档:计算耗时和字节数,更新 call/attempt,写入响应 payload,解析 usage,最后提交事务。CLI 查询只从这些记录读取,不需要重新访问上游。

状态设计把 HTTP 结果和传输结果放在一起判断:状态码小于 400 且响应完整时为 succeeded;上游返回错误码或响应不完整时为 failed;客户端取消和连接异常会分别记录 client_cancel 或 upstream_error。这使得“模型返回了 500”和“网关没收完整响应”不会被混为同一种故障。

安全、保留与验证

网关只监听 loopback,默认不会接受局域网连接。认证字段从持久化头部中剔除,CLI 的 list 和 show 也只展示清洗后的头部。项目当前没有 prompt 脱敏,因此 SQLite 中的请求和响应仍属于本机敏感数据;两天保留期和手工 purge 是 MVP 的控制手段,不能替代更严格的密钥管理或内容治理。

测试使用本地 upstream fixture,不访问真实 OpenAI,覆盖 schema 初始化、CLI、JSON/SSE 转发、认证头清洗、响应截断、usage 提取和过期清理。运行方式是:

uv sync
uv run pytest -q

启动网关的最小示例:

uv run python -m agent_gateway --database gateway.db start
OPENAI_BASE_URL=http://127.0.0.1:8787/v1 codex

这个架构把最重要的约束放在了数据流本身:客户端得到及时的输出,上游请求保持透明,数据库记录足够解释一次调用发生了什么,同时本地部署和清理成本维持在一个可控范围内。未来如果需要 Web UI、逐 chunk 检索、重试或多用户隔离,可以在现有 session/call/attempt 边界上继续扩展,而不必改变 Codex 的接入方式。

Sources

No external sources for this entry.

Related