Codex App Server:Agent协议为什么选双向JSON-RPC

把一个 Agent 嵌进 IDE、桌面应用或者 Web,不是简单暴露一个 /chat 接口就够了。

用户一句“跑测试并修掉失败项”,背后可能产生:

用户输入
模型增量输出
Shell 执行
文件 Diff
审批请求
测试结果
新的模型回合
最终回答

OpenAI 最近公开 Codex App Server 的设计时,给了一个非常值得借鉴的答案:Codex CLI、IDE Extension、Web 和 Desktop App 底层复用同一套 Harness,通过一个双向 JSON-RPC 风格协议把 Agent Loop 暴露给客户端。

真正有价值的不是“用了 JSON-RPC”,而是它把 Agent 交互拆成了三个稳定原语:

Thread
Turn
Item

再用事件生命周期把流式进度、Tool、Diff 和 Approval 统一起来。

这比把 Agent 当成一个长连接聊天接口更接近生产系统。

为什么普通 HTTP request/response 很快就不够用

传统接口:

POST /chat

请求:

{
  "message": "run tests and summarize failures"
}

响应:

{
  "answer": "..."
}

对于 Agent,这个模型丢掉了大量信息。

测试运行 40 秒期间,客户端需要知道:

当前正在执行什么?
有没有文件修改?
是否需要用户批准?
任务能否取消?
断线后怎么恢复?

所以一个用户请求对应的不再是一个响应,而是:

1 Request
→ N Events
→ 可能再由 Server 发起 Request

这也是为什么协议必须双向。

OpenAI 的三个核心原语

Thread

Thread 是一条长期 Agent 会话。

它可以:

create
resume
fork
archive

并持久化事件历史。

这解决的是:

浏览器关闭
IDE 重启
网络断开

之后还能恢复同一任务上下文。

Turn

Turn 是一次用户驱动的工作单元。

例如:

“运行测试并总结失败。”

从输入开始,到 Agent 完成这一轮工作结束。

一个 Thread 可以包含多个 Turn。

Item

Item 是最小原子单元。

类型可以是:

User Message
Agent Message
Tool Execution
Approval Request
Diff

Item 有明确生命周期:

item/started
item/*/delta
item/completed

这个模型特别适合 UI。

为什么 started → delta → completed 很实用

例如 Shell Tool:

{
  "method": "item/started",
  "params": {
    "item_id": "tool-91",
    "type": "shell_execution",
    "command": "mvn test"
  }
}

执行过程中:

{
  "method": "item/shell/delta",
  "params": {
    "item_id": "tool-91",
    "chunk": "Tests run: 128..."
  }
}

结束:

{
  "method": "item/completed",
  "params": {
    "item_id": "tool-91",
    "exit_code": 1,
    "duration_ms": 18232
  }
}

客户端不需要猜:

这条日志是不是已经结束?

状态由协议明确表达。

这和 SSE 有什么区别

SSE 很适合:

Server → Client

持续推送事件。

但 Agent 还需要:

Server → Client:需要 Approval
Client → Server:Approve / Deny

双向 JSON-RPC 更自然。

在 Web 场景,OpenAI 的做法是:浏览器到 Codex Backend 使用 HTTP + SSE;Worker 内部启动 App Server,并通过长期 JSON-RPC/stdin-stdout 通道与 Harness 通信。

所以协议层可以分成:

Browser Transport
和
Agent Harness Protocol

不必强行使用同一种技术。

Approval 为什么必须由 Server 主动发请求

假设 Agent 想执行:

rm -rf build/generated

或者修改文件。

Server 可以主动发:

{
  "id": "approval-12",
  "method": "approval/request",
  "params": {
    "action": "shell",
    "command": "...",
    "risk": "MEDIUM"
  }
}

客户端返回:

{
  "id": "approval-12",
  "result": {
    "approved": true
  }
}

Agent Turn 在此期间暂停。

这和“模型输出一句请确认,然后下一轮希望用户回答 yes”差别很大。

前者是协议级状态。

后者只是聊天约定。

我会把 Approval 做成一等协议对象

public record ApprovalRequest(
        String approvalId,
        String threadId,
        String turnId,
        String itemId,
        ActionType actionType,
        String actionHash,
        RiskLevel risk,
        Instant expiresAt) {
}

Response:

public record ApprovalResponse(
        String approvalId,
        boolean approved,
        String decidedBy,
        Instant decidedAt) {
}

执行时再次验证 actionHash

防止批准后参数被替换。

initialize Handshake 不是多余仪式

Codex App Server 要求客户端先发一次 initialize

这让双方可以协商:

Protocol Version
Capabilities
Feature Flags
Defaults

例如:

{
  "id": 1,
  "method": "initialize",
  "params": {
    "client": "my-ide",
    "protocol_version": "2",
    "capabilities": {
      "diff_stream": true,
      "approval_ui": true
    }
  }
}

Server:

{
  "id": 1,
  "result": {
    "protocol_version": "2",
    "server_features": {
      "thread_fork": true,
      "skills": true
    }
  }
}

这对长期兼容非常关键。

Agent Protocol 最难的是兼容,不是能传 JSON

客户端发布周期不同:

VS Code Extension
JetBrains
Xcode
Desktop App
企业内部 IDE

Server 可能每周更新。

如果协议没有:

Version
Optional Field
Feature Discovery
Backward Compatibility

每次服务端升级都可能把旧客户端打坏。

所以协议对象最好遵循:

新增字段优先
旧字段保留兼容期
未知字段客户端忽略
Breaking Change 升版本

Codex 使用 JSONL over stdio 的原因

OpenAI 说明里特别提到,它是一个“JSON-RPC lite”:保留 Request / Response / Notification 的形状,但不严格使用标准 JSON-RPC 2.0 Header,并通过 JSONL over stdio framing。

本地 IDE 很适合这种模式:

IDE
↓ spawn
App Server Child Process
↓ stdin/stdout
JSONL

优势:

不需要额外端口
权限边界简单
进程生命周期容易绑定
日志容易隔离

本地集成最重要的一件事:Pin Binary Version

OpenAI 的 VS Code Extension 和 Desktop App 会打包经过测试的平台二进制,确保客户端运行的是验证过的 App Server 版本。

这个细节非常重要。

不要:

IDE v1.8
自动连接系统里随机的 codex 最新版

应该:

Client Version
→ Tested Server Version Range

例如:

app_server:
  min_version: 2.4.0
  tested_version: 2.6.1
  max_compatible: 2.x

Web 场景为什么必须 Server-side State

OpenAI 的 Web Runtime 会:

Worker
→ Provision Container
→ Checkout Workspace
→ Launch App Server
→ 持久通道

浏览器 Tab 不是 Source of Truth。

因为:

Tab 会关
网络会断
笔记本会休眠

长任务必须继续。

所以:

State
Progress
Thread Event

保存在 Server。

新浏览器 Session 连接以后:

Replay Missing Events
→ Catch Up

我会给事件增加 Sequence Number

{
  "thread_id": "th-81",
  "seq": 184,
  "method": "item/completed",
  "params": {}
}

客户端保存:

last_seq = 177

重连:

GET /threads/th-81/events?after=177

避免断线期间丢事件。

Event Replay 必须幂等

客户端可能重复收到:

seq=184

UI Projection 应该按:

thread_id + seq

去重。

不要重复创建 Artifact 或重复弹 Approval。

MCP 和 App Server 不是二选一

OpenAI 也明确区分了两者。

如果你已经有 MCP Workflow,只想:

把 Codex 当一个 Tool 调用

codex mcp-server 很适合。

但 MCP 暴露的是通用工具语义。

它不一定能完整表达:

Diff Stream
Thread Lifecycle
Rich Approval
Session-specific Events

如果要做完整 IDE 体验,App Server 更适合。

Cross-provider Protocol 也会遇到“最小公分母”问题

为了同时兼容多个 Agent Provider,协议往往只保留大家都有的功能。

最终可能变成:

message
call_tool
result

但某个 Provider 有:

Diff Artifact
Approval Resume
Fork Thread
Special Tool State

就很难表达。

所以选协议时要先问:

我要的是可移植性
还是完整 Harness 能力?

三种集成方式,我会这样选

CI 一次性任务

Codex Exec

特点:

单命令
跑完退出
明确 Exit Code

TypeScript Server Integration

Codex SDK

不用自己写完整 JSON-RPC Binding。

IDE / Desktop / Rich Agent UI

App Server

因为需要:

Thread
Streaming
Diff
Approval
Resume

一个 Spring Boot Client 骨架

虽然 App Server 通常作为本地子进程运行,Java 客户端也可以做一个 Process Adapter。

public final class CodexAppServerProcess {

    private final Process process;
    private final BufferedWriter writer;
    private final BufferedReader reader;

    public CodexAppServerProcess(
            Path binary) throws IOException {

        this.process = new ProcessBuilder(
                binary.toString(),
                "app-server")
                .redirectErrorStream(false)
                .start();

        this.writer = new BufferedWriter(
                new OutputStreamWriter(
                        process.getOutputStream()));

        this.reader = new BufferedReader(
                new InputStreamReader(
                        process.getInputStream()));
    }
}

发送 JSONL:

public synchronized void send(
        JsonNode request) throws IOException {

    writer.write(request.toString());
    writer.newLine();
    writer.flush();
}

Reader 独立线程持续消费 Event。

不要在 Reader 线程里直接做业务

差:

read line
→ parse
→ update DB
→ call UI
→ approve logic

一个慢操作会阻塞整个事件流。

正确:

Reader
→ Parse
→ Event Queue
→ Consumers

Reader 只负责快速收包。

每个 Thread 独立 Mailbox

Map>

避免某个 Thread 的大 Diff 阻塞其他任务。

如果单进程承载多个 Thread,还要限制:

Maximum Threads
Event Queue Size
Per-thread Memory

Backpressure 必须有

Tool 输出可能非常大。

例如:

mvn test

产生几十 MB 日志。

不要把全部 delta 无限制塞进内存。

可以:

UI Stream:最后 N KB
完整日志:Artifact Store

例如:

stream:
  max_buffer_kb: 512
  full_log_to_artifact: true

Diff 同样不要只当文本

应该保存:

Base Commit
File Path
Patch Hash
Generated By Turn

例如:

public record DiffArtifact(
        String artifactId,
        String threadId,
        String turnId,
        String baseCommit,
        String patchRef,
        String sha256) {
}

这样重连、Review 和审计都能复用。

一个 Agent Harness Protocol 最少要能回答 8 个问题

1. Thread 怎么创建和恢复?
2. 一次用户请求的 Turn 边界在哪?
3. 中间步骤如何流式传输?
4. Tool 和 Diff 怎么表示?
5. Approval 怎么暂停和恢复?
6. 断线后怎么 Catch Up?
7. Client/Server 版本怎么协商?
8. 如何取消一个正在执行的 Turn?

如果协议只有:

send_message
receive_message

做复杂 Agent UI 很快就会补丁遍地。


Codex App Server 最值得借鉴的不是 JSON-RPC 这个技术选型本身,而是:它没有把 Agent 当成一个更长的 Chat API。

Thread、Turn、Item、Approval、Diff 和 Event Lifecycle 都是明确协议对象。

这让:

IDE
Web
Desktop
CLI

可以复用同一 Harness,而不是每个客户端重新实现一遍 Agent Loop。

如果你正在做自己的 Agent 平台,我会优先把协议原语设计清楚,再决定到底用 WebSocket、SSE、stdio 还是 JSON-RPC。

传输层可以换。

Thread、Turn、Item 和可恢复事件语义一旦设计错,后面很难补。


更多企业级 AI 应用、Agent、RAG 与大模型工程化内容,我会继续整理在 智元界

https://www.zyentor.com/