漫话 Agent Harness:ACP——终端和 Agent 之间怎么说话
1. 先捋清楚:Agent Harness、ACP、终端,三者在这张网里各占什么位
1.1 没有 ACP 之前,终端和 Agent 是怎么“各说各话”的
先说一个我经常在社区里看到的困惑:大家在终端里跑codex、claude这类 Agent 时,终端上能看到对话流、能看到 Agent 读写文件的动作、能弹权限确认,好像一切都“很顺”。但一旦你想自己写一个客户端去驱动 Agent,或者想把 Tabby、Zed 这类终端工具接进自己的 Agent 工作流里,立刻就会撞上一堵墙——你根本不知道 Agent 到底在用什么格式跟终端“说话”。
这就是问题的根源:很长一段时间里,Agent 和终端之间的交互没有统一协议。每家 Agent 都有自己的输出格式,有的往 stdout 里输出带 ANSI 颜色码的富文本,有的用特定的转义序列来表示“等我一下,我在思考”,有的甚至直接把内部工具调用的细节塞进终端输出里。你要么写一堆正则去解析这些彩色的字符流,要么乖乖去调厂商提供的 SDK,被绑死在某个生态里。
早期我做 Agent 集成的时候,最痛苦的一件事就是:我想写一个极简的终端 UI 来展示 Codex 的运行状态,但 Codex 的输出里混着进度条、颜色码、交互式提示符,我拿到的是一个“给人看”的终端流,而不是“给程序吃”的结构化数据。后来发现不是 Codex 做得差,而是终端和 Agent 之间压根没有一个公用的“普通话”。
1.2 Harness 是外壳,ACP 是对话协议
先给两个词定个调。
Agent Harness,我习惯叫它“Agent 壳”或者“Agent 运行底座”。它负责把 Agent 放进去跑起来:定义 Agent 的进程生命周期、工作目录、环境变量、标准输入输出、信号处理、会话状态机,甚至包括 Agent 能调用哪些工具、权限边界是什么、超时怎么处理。你可以把它理解成“安全带”——它把 Agent 这件麻烦事儿稳稳地固定住,让你可以在外面安全地操作它。Codex CLI、Claude Code 这类工具本身就内置了一个 Harness,但它们各自为政,没有对外暴露统一的接口。
ACP(Agent Client Protocol)就是来解决这个“各自为政”的。它是 Zed 团队提出来的一套开放协议,目标非常明确:给“客户端”(终端、IDE、编辑器、任何 UI)和“Agent”(运行在 Harness 里的 AI 智能体)之间定义一套标准化的通信方式。注意,它解决的不是“人和 Agent 怎么聊天”的问题,而是“程序和 Agent 怎么互相调用”的问题。人和 Agent 之间可以用自然语言,但程序(终端工具)和 Agent 之间,必须用结构化的消息。
打个比方:Agent Harness 是一台发动机,ACP 是发动机和变速箱之间的接口标准。以前每台发动机的接口都不一样,你为了接一台新发动机得专门做一个适配器;有了 ACP,发动机只要按标准接口输出动力,任何变速箱都能接。
1.3 一个最小架构示例:codex + 终端
纸上谈兵没用,我直接画一个真实场景。
假设你在终端里跑:
codex此时发生了什么?终端模拟器(比如 Tabby)负责渲染字符流,codex 进程负责跑模型、决策、调用工具。但在这两者之间,还有一层隐性交互:codex 要想读文件,得通过某种方式拿到文件内容;codex 想执行 shell 命令,得请求获得授权;codex 想要“请用户输入一个 API Key”,得想办法把这个问题渲染到终端上。
这些动作,在传统场景下,是通过“神奇的转义序列”和“终端模拟器的特殊解析”来实现的。在 ACP 的场景下,就变成了更干净的逻辑:
- 终端是Client,它提供一个 UI,显示 Agent 的状态、工具调用、权限请求;
- codex 是Agent,它运行在 Agent Harness 里,通过 ACP 与 Client 通信。
举个例子,当 Agent 需要执行npm install时,不是直接在它的"脑子里"执行,而是通过 ACP 向 Client 发一个permission request,说:“我想要执行这个命令,用户你批准吗?”Client 在界面上弹一个确认框,用户点击同意后,Client 再通过 ACP 告诉 Agent 结果,并执行这个命令。整个过程,Agent 不再自己偷偷摸摸干活,它的每一步操作都是可审计的。
这个转变,才是 ACP 真正的价值所在:Agent 不再是一头蒙眼拉磨的驴,而是在一个透明的、可控的管线上运作的程序。
2. ACP 到底“说”了什么:协议三件套拆解
2.1 initialize / task / capabilities:三对关键请求
ACP 不是一个庞大的怪物协议,它的核心可以拆成三块:初始化会话(initialize)、任务调度(task)、能力协商(capabilities)。
第一块:initialize。
客户端和 Agent 开始对话的第一步,和 TCP 握手很像。Client 发一个initialize请求,告诉 Agent:“我是谁、我支持哪些协议版本、我期望你用什么格式回话。”Agent 收到后,回一个initialize结果,告诉 Client:“协议版本 OK、我支持这些能力、我的模型列表是什么。”之后,双方还要交换一次initialized通知,确认握手完成。
这套机制看起来很仪式化,但实际意义重大:它让 Client 和 Agent 可以在不知道对方具体实现的情况下达成共识。就像两个人第一次见面先互换名片,确定“你会中文、我会英文,那我们用双语沟通”,而不是一上来就开聊,聊到一半才发现互相听不懂。
第二块:task。
在 ACP 的世界里,一次“让 Agent 做某件事”的全过程,被建模成一个 task。比如你让 Agent “重构这个函数”,这就是一个 task。task 有三个重要属性:
- task id:唯一标识,后续所有针对这个任务的请求都要带上它;
- session id:会话标识,一个 session 里可以有多个 task;
- state:任务状态,如
running、cancelled、completed、error。
你在终端里看到 Agent 正在“思考中”、然后“开始改文件”、然后“报错退出”,这些状态变化对应到 ACP 里就是task/update事件。客户端收到这些事件后,就可以在 UI 上渲染一个状态机,而不是去解析那些“闪光的花字”。
第三块:capabilities。
这是 ACP 里我觉得最出彩的设计。简单说,Client 和 Agent 在握手时可以互相声明自己的能力矩阵。比如:
- Client 说:“我支持读取文件、写入文件、执行命令、编辑文本,但我不支持打开浏览器。”
- Agent 说:“我可以并行执行多个工具调用、我支持多模态输入、我可以持久化记忆。”
这些能力声明不是摆设。有了它,协议双方就能聪明地降级和适配。如果 Client 不支持写入文件,Agent 就会改用“把文件内容返回给用户,让用户手动复制粘贴”的策略;如果 Agent 不支持并行调用,Client 就不会同时发出多个请求。
传统方案里,这些能力判断通常写死在代码里,比如“如果 Agent 的名字是 codex,那么它支持 exec 工具;如果是 claude,它支持 Read 工具”,这种判断没法扩展。ACP 把能力协商变成了运行时数据,让任何 Agent 都能接入任何客户端,这才是“开放协议”的味道。
2.2 能力协商:客户端和 Agent 互相亮底牌
能力协商不是走个过场,它对实际开发影响巨大。我举个真实场景:我写过一个功能,让 Agent 可以在终端里“接管”用户的文件编辑权限。传统实现里,我得在客户端代码里写死“Agent 可以写 /tmp 目录、可以编辑 markdown 文件”,但这些规则一多就爆了:Agent 想写 log 文件怎么办?Agent 想访问用户主目录下的 .env 怎么办?每加一个规则,我就要改一次代码,发布一次版本。
用 ACP 的 capability 机制就舒服多了。初始化时,Agent 会发送一个请求,说:“我具备edit_file能力,但只能在以下路径范围内操作”,或者 Client 会声明:“我能执行 shell 命令,但每次执行前需要用户确认。”这些声明是结构化的 JSON,客户端可以根据它们动态地渲染权限 UI。比如 Agent 说它要写~/project/src/main.rs,客户端就能弹出一个清晰的权限对话框:“Agent 请求编辑文件:src/main.rs,路径范围:~/project/src,是否允许?”
这其实就是权限漏斗的雏形:能力声明定义边界,权限请求在边界内弹性处理。没有这个机制,边界就永远是写死的,Agent 一遇到新需求就只能报错。
2.3 权限请求:Agent“喊人”的那一嗓子怎么传
权限请求是 ACP 里最贴近用户感知的部分。你肯定见过“Agent 想要执行 npm install,是否允许”之类的对话框,背后的机制就是权限请求。
在 ACP 里,Agent 向 Client 发一个request/permission请求,包含三个核心字段:method(要干什么)、arguments(具体参数)、metadata(附加上下文,比如要修改的路径、要执行的命令)。
这里有一个设计细节我非常喜欢:权限请求不等于只能“同意/拒绝”。ACP 允许 Client 返回多种结果,比如“同意一次”、“总是同意”、“拒绝本次”、“拒绝并记住我的选择”。甚至可以有条件地同意,比如“这一次允许它在 src 目录下写文件,但临时文件必须放 /tmp”。
这种灵活性对用户体验至关重要。如果只有“同意”和“拒绝”两个按钮,用户会被频繁打断;如果默认全同意,安全又无从谈起。条件性授权、会话级授权、按路径授权,这些才是实际开发中真正需要的粒度。
2.4 流式与增量:diff 大文本不会糊屏
还有一个实操里特别痛的体验问题。早期我在终端里看 Agent 生成文件,经常看到一个几千行的 diff 一次性喷出来,终端直接卡死、光标乱飞、ANSI 颜色解析错位。ACP 对这种场景的处理很优雅:它把“内容”和“内容渲染”分开,用结构化的增量事件来推送。
比如 Agent 创建了一个新文件,客户端不是收到一整坨带颜色码的文本,而是收到一个task/update事件,里面包含file_path、content、is_complete等字段。客户端拿到这些字段后,可以自己决定怎么渲染:是在 UI 里开一个代码预览面板,还是直接在终端里打印一个 diff。如果未来 Agent 改动了文件,客户端还能拿到增量 patch,而不是重新渲染整个文件。
这就是“协议”和“终端流”的本质区别:协议传递语义,终端流传递字符。ACP 把语义交给你,把渲染交给你,它只负责把话说清楚。
3. ACP 和 MCP、厂商 SDK 的边界:别把协议放在错误的层
3.1 一张表理清四个协议/框架的定位
很多刚接触的人会把 ACP 和 MCP 搞混,这两个词长得像,但定位完全不同。还有厂商 SDK(比如 Codex CLI 内置的实现、Claude Code SDK)也容易让人疑惑。我把它们放在一张表里对照:
| 协议/框架 | 定位 | 解决什么问题 | 典型场景 |
|---|---|---|---|
| MCP(Model Context Protocol) | Agent 与外部工具/数据源之间的通信协议 | Agent 如何调用文件系统、数据库、API 等外部工具 | Agent 想在项目里读文件、执行命令,通过 MCP 调一个文件工具 |
| ACP(Agent Client Protocol) | 客户端(终端/IDE)与 Agent 之间的通信协议 | 终端 UI 如何驱动 Agent、展示状态、处理授权、接收工具调用请求 | Tabby 终端里跑 codex,codex 通过 ACP 让终端 UI 显示任务状态和权限框 |
| Vendor SDK(如 Codex CLI、Claude Agent SDK) | 厂商对自家 Agent 的封装 | 让你以编程方式驱动特定厂商的 Agent | 你要在 Node 项目里直接调 codex 的接口,用官方 SDK |
| Direct Process 调用 | 直接启动 Agent 进程,解析其输出 | 极简场景,不做复杂 UI | 你只想在 shell 脚本里跑一次 codex,拿到输出即可 |
看到区别了吗?MCP 是Agent 往外看的协议,ACP 是客户端往 Agent 里看的协议。一个是手,一个是眼睛。MCP 管的是“Agent 怎么摸世界”,ACP 管的是“世界(客户端)怎么看 Agent”。两者不在一个层次,甚至可以协同使用。
3.2 选 ACP 的三个理由和两个别用它的场景
先说三个一定要上 ACP 的理由:
第一,你不想被厂商绑架。用官方 SDK 最舒服,但如果你给公司做了一个内部终端工具,今天接的是 Codex,明天要接 Claude,后天可能要接一个自研模型,每个 SDK 的 API 都不兼容,你的 UI 层要写三套适配器。ACP 让 Agent 端只要实现了协议,你的终端工具就能无缝切换。
第二,你需要结构化状态。如果你只想让 Agent 输出一段文本,终端流完全够用。但如果你要在 UI 上画任务状态机、显示工具调用栈、展示权限对话框、记录审计日志,你就需要结构化的状态事件。这些是终端流做不到的。
第三,你需要安全的授权机制。终端流模式下,Agent 的权限控制全靠终端模拟器“猜”——它看到某种转义序列就弹框,看不到就默认放行。ACP 让授权显式化,Agent 每次想动用外部资源都要通过权限请求,客户端可以精准拦截。对于企业场景,这是刚需。
再说两个别用 ACP 的场景:
极简脚本场景别用。你在 CI 里跑一个一次性任务,只需要拿到 Agent 的最终输出,直接用codex exec "..."然后截取 stdout 就够了,引入 ACP 反而是杀鸡用牛刀。
纯文本聊天场景别用。如果只是做一个聊天 UI,Agent 也只是回复一段自然语言,用 SSE 或 WebSocket 推消息已经足够。ACP 的价值在于工具调用、权限、状态机这些复杂交互,聊天场景用不上这些能力。
3.3 和 Codex CLI、Claude Code SDK 的实际关系
需要说明一个现实情况:Codex CLI 在新版本里已经内置了 ACP 支持(配置里有个acp_enabled开关,还有相关插件机制),但不同 Agent 的 ACP 支持程度并不一样,有的 Agent 只实现了部分接口。
我的建议是:在选型时,优先看目标 Agent 的 ACP 兼容度,而不是它的功能丰富度。比如 Tabby 终端现在支持 ACP 面板,它接入 codex 的方式不是靠“解析 codex 的彩色输出”,而是靠 codex 实现了 ACP 接口。如果你要接一个不支持 ACP 的 Agent,那还是得回去写解析器,这个成本要提前算清楚。
厂商 SDK 和 ACP 的关系不是竞争,而是互补。官方 SDK 是“用官方方式驱动官方 Agent”,ACP 是“用统一方式驱动任意 Agent”。如果哪天官方 SDK 不再维护了,你的 ACP 层依然能跑,这就是投资开放协议带来的长期稳定性。
4. 从零实现一个能听懂 ACP 的“极简终端外壳”
4.1 最小可行性方案:stdio + JSON-RPC 循环
说再多理论,不如直接上手写一个能跑的最小实现。ACP 走的是 JSON-RPC 2.0 over stdio:Client 把 JSON-RPC 消息写到 Agent 进程的 stdin,Agent 把响应和事件写到 stdout。就这么简单,不需要 WebSocket,不需要 HTTP 服务,不需要额外的网络端口。
我建议你从零写一个最小循环,别急着引 SDK。核心代码大概长这样:
# 假设你有一个实现了 ACP 的 agent 可执行文件 ./acp-agent --stdio然后你可以在 Python 里用 subprocess 驱动它:
import json import subprocess proc = subprocess.Popen( ["acp-agent", "--stdio"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True ) def send_rpc(method: str, params: dict, req_id: int): msg = json.dumps({"jsonrpc": "2.0", "id": req_id, "method": method, "params": params}) proc.stdin.write(msg + "\n") proc.stdin.flush() return req_id def read_rpc(): line = proc.stdout.readline() if not line: return None return json.loads(line) # 1. 初始化握手 send_rpc("initialize", { "protocolVersion": "1.0", "clientCapabilities": { "tools": {"read_file": {}, "write_file": {}, "execute_command": {}} } }, req_id=1) # 2. 读取 Agent 的 initialize 响应 resp = read_rpc() print("Agent 初始化响应:", resp) # 3. 发送 initialized 通知 send_rpc("initialized", {}, req_id=0) # 4. 创建新任务 send_rpc("task/new", { "sessionId": "session_1", "prompt": "帮我重构 src/utils.ts 里的 debounce 函数" }, req_id=2)注意细节:initialize是请求(带 id),initialized是通知(不带 id,不需要响应)。这是 JSON-RPC 2.0 的基本功,代码里容易漏。
等你跑通这个循环,你就掌握了 ACP 的骨架。之后再加任务状态、权限请求、工具调用,都是在这个循环上做加法。
4.2 信号处理与进程生命周期:Ctrl+C 会发生什么?
这是我在实际开发里踩过最深的坑,值得单独拿出来说。
在终端里,用户按下 Ctrl+C,内核会向前台进程组发送 SIGINT。如果你的 Client 和 Agent 进程在同一个进程组,问题来了:两个进程都会收到 SIGINT。Agent 收到 SIGINT 会取消当前任务,Client 收到 SIGINT 也会退出,于是一场完美协作变成了“两边都在响应,会话状态丢失”。
解决思路是:Client 需要主动接管信号处理,把“用户按 Ctrl+C”转译成 ACP 消息,而不是直接把信号透传给 Agent。比如捕获到 SIGINT 后,向 Agent 发一个task/cancel请求,等 Agent 确认取消后,再决定是否退出进程。
import signal def handle_sigint(signum, frame): print("收到 Ctrl+C,发送 task/cancel 给 Agent...") send_rpc("task/cancel", {"taskId": current_task_id}, req_id=99) signal.signal(signal.SIGINT, handle_sigint)还有一个细节:不要轻易 kill Agent 进程。Agent 可能正在写文件,强制 kill 可能导致文件损坏或状态不一致。正确做法是先取消任务,再给 Agent 一个合理的退出窗口,最后才强制结束。
如果你做的是一个长期运行的服务型 Client(比如一个后台守护进程),还要考虑 SIGTERM 的处理,确保 Agent 和 Client 都能优雅退出。
4.3 权限漏斗与工具降级:安全边界怎么写才不闹心
接着 2.2 的能力协商说。现实中,权限设计最容易犯的两个错误是:
错误一:全有或全无。要么给 Agent 全部权限(危险),要么不给任何权限(没法用)。正确做法是可降级授权:Agent 请求写文件,但客户端不支持写文件,就返回一个“降级”结果,让 Agent 改用read_file+ 输出内容给用户看。
错误二:权限规则写死在代码里。比如“允许写 /tmp 目录”这个规则如果写死在代码里,换成 Windows 路径就失效。正确做法是把规则做成可配置的,甚至让 Agent 在请求时附带一个“路径范围”字段,客户端在这个范围内动态判断。
我用 ACP 写权限漏斗的实战经验是三层结构:
- 第一层:能力声明层。Client 在握手时声明“我支持哪些工具”,Agent 声明“我可能调用哪些工具”。这个层决定“能不能提出请求”。
- 第二层:预授权规则层。用户在客户端里配置“哪些路径可以自动授权”、“哪些命令需要确认”。这个层决定“请求来了是直接放行还是弹框”。
- 第三层:会话内授权层。用户在某次会话里手动批准了某个请求,这个授权状态只对该会话有效。这个层决定“同一个请求第二次来还要不要确认”。
这三层互相配合,权限才能既安全又高效。你不需要一次实现全部,但至少要意识到:权限不是一个二元开关,而是一个漏斗。
4.4 和官方 SDK / 插件机制如何共存
如果你已经用了 Codex CLI 或 Claude Code SDK,别急着推翻重来。它们本质上是一个“自带 Harness 的 Agent 运行器”,而 ACP 是它们的对外接口之一。
我推荐的共存策略是:在 Harness 之外套一层 ACP 适配器。Harness 负责管理 Agent 的生命周期、模型调用、工具执行;适配器负责把 Harness 的内部事件翻译成 ACP 的 JSON-RPC 消息。这样你既能复用官方 SDK 的成熟能力,又能享受 ACP 带来的自由。
具体到 Codex CLI,它在配置里已支持开启 ACP 相关的插件能力(比如acp_enabled),你可以把它当成一个“原生支持 ACP 的 Agent”来使用,省去自己写适配器的工作。但要注意版本差异,不同版本的协议兼容性可能有细微差别,升级前先跑一遍握手测试。
5. 几个实战经验和性能建议
5.1 日志:stdout 纯协议,stderr 给人看
这条建议看起来简单,但无数人栽过。ACP 走 stdio,所以stdout 必须是纯净的 JSON-RPC 消息流,一个额外的字节都不能混。一旦你在 Agent 的 stdout 里打印了一行调试日志,Client 的 JSON 解析就会崩,而且崩得莫名其妙。
那开发时想打日志怎么办?答案是 stderr。stderr 不走协议通道,你想怎么打就怎么打。更稳妥的做法是打到一个独立日志文件,甚至用系统日志服务收集。总之:stdout 是机器吃的,stderr 是人吃的,千万别搞混。
5.2 心跳与超时:Agent 卡住不只是“慢”的问题
大型语言模型在推理时,可能几十秒不吐一个字,这是正常的“思考中”。但在 ACP 里,这种等待会让 Client 产生焦虑:是 Agent 死了,还是它在想?没有心跳机制的话,你会碰到“超时误判”的问题。
我的建议是:不要只依赖 ACP 消息本身做判活,加一个额外的 I/O 监控层。如果 Client 的 stdout 超过 30 秒没有任何新字节,就判定为“疑似卡死”,此时可以主动发一个task/cancel,或者通过 stderr 日志检查 Agent 内部状态。如果你想区分“思考”和“卡死”,可以看 Agent 的 stderr 是否有周期性的进度日志,很多 Agent 会定期写“still working”之类的日志。
另一个相关话题是延迟测量。如果你在做一个评估工具,建议同时记录两个指标:首字节延迟(发任务后到收到第一条事件的时间)和总时长(到任务完成的时间)。首字节延迟高说明 Agent 启动/模型推理慢;总时长长但同时首字节快,说明在工具调用阶段卡住了。这两个指标能帮你快速定位瓶颈。
5.3 错误码与结构化返回:别只给一个 ERROR 字符串
最后一个实战建议,也是我觉得 ACP 设计里值得点赞的细节:错误处理允许你返回结构化的错误对象,而不仅仅是一个字符串。
比如 Agent 执行工具调用时失败了,正确的做法是返回:
{ "jsonrpc": "2.0", "id": 42, "error": { "code": -32000, "message": "Tool execution failed", "data": { "toolName": "read_file", "path": "/nonexistent/file.txt", "reason": "ENOENT" } } }注意:error.data是一个对象,不是字符串。客户端拿到这个对象后,可以在 UI 上展示结构化的错误信息,比如“文件不存在,路径:/nonexistent/file.txt,原因:ENOENT”,而不是让用户读一大段英文报错。
对于自动化测试工具来说,结构化错误还能帮助你做断言:检查error.data.reason === "ENOENT",而不是用正则去匹配错误消息。这一条我认为是 ACP 在实践中被低估的优点。
另外,自定义错误码时,避开 JSON-RPC 预留的 -32700 到 -32099 区间,从 1 开始编号,并在文档里维护一个错误码表,这对后续排查问题能省下大量时间。
ACP 带给我的最大感受是,它把 Agent 交互从“写解析器”的体力活里解放出来,让你能把精力放在真正有价值的 UI、授权、可观测性上。如果你也在做终端工具或 Agent 工作台,建议先花一个下午跑通最小协议循环,再慢慢扩展。上手之后你会发现,Agent 和终端之间“说话”这件事,本来就不应该是一场混乱的拼写大赛。