前后端分离的本质:Agent 的生命周期为什么必须独立于交互界面

这是《写一个 Coding Agent Harness and TUI 》系列的第 6 篇。 前五篇搭好了 agent 的全部内部逻辑——LLM 客户端、工具系统、Turn Loop、文件编辑、TUI。 但有一个问题悬而未决:TUI 关了,agent 还活着吗?


当前的架构里,agent 和 TUI 共享同一个进程。共享同一个 main 函数。共享同一个 tokio runtime。共享同一个生命周期。

你打开 TUI,agent 启动。聊了半个小时,agent 了解了你的项目结构,积累了 30 轮对话上下文,刚完成一个复杂的重构。你关了 TUI——agent 死了。所有状态丢失。

这不是架构设计。这是一个时间炸弹。 你不知道它什么时候爆——你不知道哪一次用户关 TUI 的时候正好是 agent 刚完成一个 20 分钟的任务、正要输出结果的前一秒。

进程的死亡有两种类型。一种是你主动杀了它(Ctrl+C)——你知道你失去了什么。另一种是你无意中杀了它(关窗口)——你不知道你失去了什么。当前架构让第二种死亡成为可能,而且不需要任何确认。TUI 进程退出 = agent 进程退出 = 所有状态被操作系统回收。你甚至没有一行日志告诉你"agent 在被强制终止时正在执行什么任务"。

这篇要解决的问题不是"怎么把 TUI 和 agent 拆成两个进程"——是"agent 的状态所有权应该属于谁,以及这个所有权如何通过 IPC 协议来实现"。

〇、元问题:Agent 的状态属于谁

一个思想实验

你有一个银行账户管理系统。它有一个 Web 前端和一个后端服务。你关了浏览器——你的账户还在。因为账户状态不属于浏览器。它属于后端。

Coding agent 的状态是什么?对话历史(30 轮 user + assistant + tool 消息)。工具执行上下文(当前工作目录、环境变量)。LLM 的 reasoning chain(CoT 推理过程)。这些合在一起,构成了 agent 的"认知状态"——它"知道"什么、“做过"什么、“正在做什么”。

这个认知状态应该属于谁?

当前架构:属于 TUI 进程。TUI 退出 → 状态被操作系统回收。 正确架构:属于 Agent Server 进程。TUI 只是一个 Client——连接上去,发送指令,显示结果。断开连接,Agent 继续运行。

这不仅仅是"后台运行”——这是 agent 的三种新能力

能力 1:跨会话连续性。 你昨天的对话,今天还能继续。Agent Server 进程没死——你断开 TUI 只是断开了一个 Client 连接。重新连接后,agent 的对话历史还在内存里。

能力 2:多前端共享。 TUI 在跑,headless CLI 也在用——两个 Client 连接到同一个 Agent Server,看到同一份对话历史。这不是"多开"——是"共享同一个 agent 大脑"。

能力 3:后台任务。 你对 agent 说"每 30 分钟跑一次 cargo test,失败了通知我",然后关掉 TUI。Agent Server 继续运行,到时间自动执行——因为它是独立进程。TUI 的生死不影响它。

这三个能力共享同一个底层前提:agent 的状态不绑定在任何交互界面的生命周期上。

一、IPC 通道:为什么 Unix socket 而不是 TCP 或 stdio

三个候选方案

Unix SocketTCP (127.0.0.1)stdio
可达范围本机局域网/公网父子进程
安全性文件权限(0600)需要 TLS + 认证进程边界
端口管理无(文件路径)需要选端口 + 防冲突
连接语义可以检测对端断开需要心跳管道破裂 = 进程死

为什么淘汰 TCP

TCP 引入了三个你在本机 IPC 场景下不需要的东西。

第一,端口管理。你需要选一个端口号。不能太常见(避免冲突),不能太随机(用户找不到)。你需要处理 bind: address already in use——另一个程序占了这个端口。你需要担心防火墙规则——有些 Linux 发行版默认 block 本地回环端口上的非标准服务。

第二,安全配置。TCP 连接默认没有认证。任何人都可以在本机连到 127.0.0.1 的任意端口——只要他们知道端口号。你需要加 TLS 或 token-based auth,而 TLS 引入了证书管理——另一个你在本机 IPC 场景下不应该需要的东西。

第三,连接检测。TCP 连接的对端断开,你只能通过 read() == 0(对端发 FIN)或心跳检测来发现。没有内核级的"对端退出"通知。Unix socket 在这一点上更好:对端关闭连接时,你的 read() 立刻收到 EOF。

为什么淘汰 stdio

TUI 已经在用 stdin/stdout。 你不能把 Agent 通信也塞进同一对 stdin/stdout——TUI 的终端 escape code 和 Agent 的 JSON-RPC 消息是两种完全不同的数据流。

你可以在启动 TUI 之前 fork 一个 Agent Server 子进程,用一对 pipe 通信。但这样又回到进程生命周期绑定——父进程(TUI)退出 = 子进程(Agent)可能被 init 进程接管,但 pipe 断了。

为什么 Unix socket 是对的

Unix socket 的文件系统命名空间 = 按名访问,不需要端口。 文件权限 0600 = 只有当前用户能连接,连 root 都要 sudo。connect() 失败 = Agent 没在运行——不需要单独的健康检查端点。

Linux 和 macOS 都支持。Windows 通过 tokio::net::windows::named_pipe 有等价机制——这就是为什么 grok-build 有跨平台的 socket 抽象。

grok-build: shell/src/leader/mod.rs

二、JSON-RPC 2.0:为什么不是 gRPC

JSON-RPC 2.0 的完整规则——4 条

请求:   {"jsonrpc":"2.0","id":1,"method":"agent/run","params":{...}}
响应:   {"jsonrpc":"2.0","id":1,"result":{...}}
错误:   {"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"..."}}
通知:   {"jsonrpc":"2.0","method":"notification/update","params":{...}}  // 无 id

这就是全部协议。 没有 service definition。没有 code generation。没有 protobuf 编译。没有强类型接口。method 就是一个字符串——"agent/run""session/resume""notification/tool_update"。加一个新 method = 前后端各自加一行字符串匹配。

gRPC 在这个场景下的代价

gRPC 的强类型优势在快速迭代阶段变成了劣势。 调试 agent 的过程中,你可能今天发现需要一个新的 method "agent/interject"(用于中途给 agent 注入指令)。用 gRPC:

  1. .proto 文件,添加新 RPC
  2. 运行 tonic-build 重新生成 Rust 代码
  3. 前后端各自 cargo update(如果 proto 在共享 crate 里)
  4. 如果 proto 字段改了——所有引用点都要更新编译错误(这是强类型的正面,但在这个阶段是摩擦)

JSON-RPC:你在 Server 端加一行 "agent/interject" => handle_interject。在 Client 端加一行 .method("agent/interject")零编译依赖。零代码生成。

JSON-RPC 的另一个隐藏优势:人类可读

你可以在终端里用 echo 测试 Server:

echo '{"jsonrpc":"2.0","id":1,"method":"agent/run","params":{"prompt":"hello"}}' | nc -U /tmp/agent.sock

你不能用 echo 测试 gRPC。 调试一个运行中的 Agent Server——你不需要启动 Client,不需要写任何代码。一个 nc 命令就能发请求、看响应。

人类可读性 = 可调试性 = 开发速度。 在 agent 这种需要大量迭代的系统中,开发速度比接口强类型更重要。

grok-build 的选型

grok-build 的 ACP 协议基于 JSON-RPC 2.0。不是因为 JSON-RPC “更好”——是因为在这个项目的阶段,协议的修改频率远高于单个 method 的调用频率。JSON-RPC 的零成本 method 添加完美匹配了这种开发模式。

grok-build: shell/src/leader/protocol.rs

三、通知 vs 请求-响应——什么时候不需要回复

JSON-RPC 的两种消息类型

请求(有 id):Client 发,Server 收到后必须返回一个带相同 id 的响应。适合 RPC:agent/runsession/createagent/cancel

通知(无 id):Server 发,Client 收到后不需要回复。适合状态推送:notification/tool_updatenotification/streaming_token

工具执行状态选哪种

agent 执行一个 Turn 时,Server 需要把中间状态实时推给 Client(TUI)。工具开始执行时推一条 "🔧 bash(cargo build) 开始执行"。工具完成时推 "✅ bash(cargo build) 成功 (exit 0, 1.2s)"

如果用请求-响应(Server 发请求,Client 回响应):每条状态推送 = 2 条消息。50 个状态更新 = 100 条消息。Client 的 ack 逻辑纯属浪费——Server 不需要知道 Client 是否收到了(没收到也没关系,下一条更新会覆盖)。

通知就是为这种场景设计的——单向、高频、无状态依赖。 丢一两条通知无所谓。收到重复通知没影响。对实时性没有硬要求(早 100ms 晚 100ms 用户感知不到)。

什么时候用请求-响应

agent/run:Client 必须等 Server 返回结果。如果发了请求没等到响应——Client 需要知道是 Server 在处理中还是挂了。

agent/cancel:Client 必须确认取消请求被 Server 收到。如果只是发条通知——Server 可能没收到(消息丢了),Client 以为取消了但 Server 还在跑。

判断标准:调用方是否需要确认操作被接收和执行。 需要 → 请求-响应。不需要 → 通知。

四、Leader-Follower 模式:connect_or_spawn()

用户不需要知道"启动 Agent"

当前体验:用户先启动 agent-server,再启动 agent-tui。两个终端窗口。用户关 TUI 后想重连?还得再启动一遍——如果上次的 Agent Server 没被杀,还要先杀它。

这不是"多了一步"的问题——是"用户需要理解 agent 的进程模型"的问题。 用户不应该知道 agent 是一个独立进程。用户只应该看到"打开 TUI → 开始对话"。

connect_or_spawn 的三步逻辑

async fn connect_or_spawn(socket_path: &Path) -> Result<AgentClient> {
    // 第 1 步:尝试连接已有 socket
    if let Ok(client) = AgentClient::connect(socket_path).await {
        return Ok(client);  // Agent 已经在运行,复用
    }

    // 第 2 步:获取文件锁——确保只有一个进程启动 Agent
    let lock = LeaderLock::acquire()?;

    // 第 3 步:spawn Agent 子进程,等它就绪
    let mut child = Command::new("agent-server")
        .arg("--socket").arg(socket_path)
        .spawn()?;
    wait_for_socket(socket_path, Duration::from_secs(5)).await?;
    AgentClient::connect(socket_path).await
}

第 2 步是最关键的安全措施。 没有文件锁:两个 TUI 实例同时启动——两个都发现 socket 不存在——两个都 spawn 了 Agent Server。两个 Agent Server 竞争同一个 socket 路径——后绑定的失败。第一个 TUI 连接到了 A Server,第二个 TUI 连接到了 B Server(如果 B 用了不同的 socket 路径)——两个用户看到不同的对话历史,互相不知道对方的存在。

文件锁保证了"只有一个进程能启动 Agent"这个不变量。 第二个 TUI 启动时,第一步连接可能成功(第一个 Agent 已经就绪),也可能在等待锁时发现第一个 Agent 已经在运行。

grok-build: shell/src/leader/lock.rs

连接断开后的行为

TUI 断开连接 → Agent Server 检测到 socket EOF → 什么都不做。 Agent Server 继续运行,对话历史保留在内存。TUI 重新连接 → session/resume → Agent Server 返回当前状态 → TUI 恢复到断开前的对话。

这是 agent “持久实体"模型的关键优势。 TUI 是一个 disposable 的窗口。Agent 是一个 persistent 的大脑。窗口坏了可以重开。大脑不能因为窗口关了而失忆。

五、代码

~300 行,tutorial-code/part-06-protocol/

src/
├── protocol/
│   ├── rpc.rs          ← Request/Response/Notification 类型 + 序列化
│   ├── methods.rs      ← 5 个 ACP method 定义
│   └── client.rs       ← AgentClient: connect + send + recv
├── server.rs           ← UnixListener + per-connection task
├── leader.rs           ← connect_or_spawn + LeaderLock
└── main.rs             ← 启动 TUI 或 Server

六、总结:不是"拆分进程”——是"重新定义国家边界"

边界属于谁为什么
对话历史Agent Serveragent 的认知状态不绑定在 UI 生命周期上
工具注册表Agent Server多个 Client 共享同一个工具箱
渲染状态TUI ClientUI 的 scroll position 是 Client 本地关心的
用户输入TUI Client → 通过 RPC 发给 Server输入最终由 Server 处理

这是 agent 从"我用一次就关的 CLI 工具"到"持续运行的智能服务"的跃迁。 下一篇在这个跃迁的基础上加上安全层——agent 自主性的另一面是破坏能力。如何在"自主"和"安全"之间画一条合理且可调的线。

完整代码在 tutorial-code/part-06-protocol/


📮 持续关注本系列。 扫码回复"agent"获取 PDF 合集。

下一篇:安全设计的核心矛盾——自主性 vs 安全性,以及三模式如何解开这个矛盾