ACP与Agent Harness:终端和AI Agent的标准化通信协议
2026/9/8 15:12:52 网站建设 项目流程

漫话 Agent Harness:ACP——终端和 Agent 之间怎么说话

1. 先捋清楚:Agent Harness、ACP、终端,三者在这张网里各占什么位

1.1 没有 ACP 之前,终端和 Agent 是怎么“各说各话”的

先说一个我经常在社区里看到的困惑:大家在终端里跑codexclaude这类 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:任务状态,如runningcancelledcompletederror

你在终端里看到 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_pathcontentis_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 和终端之间“说话”这件事,本来就不应该是一场混乱的拼写大赛。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询