ACP 详解(选修)

13,210 字 · 预计阅读 38 分钟

前面拆解 dsh-ccgui 的时候,你可能留意过它自带的 cordis.patch.yml。我们节选几项出来看看:

yaml
agents:
  - id: claude
    name: Claude Code
    command: npx
    args: [-y, '@agentclientprotocol/claude-agent-acp@0.101.0']
  - id: kimi
    name: Kimi CLI
    command: npx
    args: [-y, '@moonshot-ai/kimi-code@2.5.2', acp]
  - id: opencode
    name: OpenCode
    command: opencode
    args: [acp]

看到这儿你可能会好奇:明明是不同公司做的 7 个 Agent,为什么 dsh-ccgui 光靠一个插件就能把它们全驱动起来?

答案很简单——这 7 个程序启动之后,讲的其实是同一种「语言」:ACP(Agent Client Protocol,Agent 客户端协议)。

这一节我们就把这层窗户纸捅破。读完之后,你能搞定两件事:

  • 随便抓一个 CLI,能逐行看懂它和 dsh-ccgui 在后台来回传的到底是什么消息;
  • 不装任何第三方依赖,自己手搓一个不到 100 行的 ACP Agent,直接塞进 dsh-ccgui 的菜单里跑起来。

一、ACP 到底解决什么问题?

咱们先算笔账。假设市面上有 4 个界面(Zed、JetBrains、Neovim、DSH)想接入 AI Agent,同时有 6 个 Agent(Claude Code、Codex、Kimi、OpenCode、PI、OMP)也想进驻这些界面。

如果大家没有共同的约定,每个界面就得给每个 Agent 单独写一套对接代码,算下来一共要写 $4 \times 6 = 24$ 份。哪天又冒出来一个新 Agent,4 个界面的维护者又得苦哈哈地各自加班再写一份。

但要是大家坐下来约定好讲同一套协议,事情就简单多了:每个界面按协议实现一次,每个 Agent 也实现一次,总共只要 $4 + 6 = 10$ 份代码。以后再有新 Agent 出来,只要它自己把协议接好,所有界面立马就能直接用。

小黑把四个界面与六个 Agent 的乱线接到共同的 ACP 接口,对接实现从 4×6 变成 4+6

这套大家共同遵守的协议就是 ACP。为了后面好聊,咱们先把协议里的两个核心角色定下来:

  • Client(客户端):直接跟人打交道的那一端,负责展示对话、渲染工具调用卡片、弹出权限审批框。像 Zed、JetBrains,还有装了 dsh-ccgui 的 DSH,在这套体系里都属于 Client。
  • Agent:在后台真正干苦力的那一端,负责调大模型、读写本地文件、跑终端命令。像 Claude Code、Codex 在这里都是 Agent。

如果你熟悉编辑器生态,肯定觉得这个套路很眼熟。没错,当年的 LSP(Language Server Protocol,语言服务器协议)就是这么干的——编辑器根本不用自己去解析 Python 语法,只要按 LSP 标准去问 Python 的语言服务器,就能直接拿到代码补全和定义跳转的结果。ACP 其实就是把 LSP 的成功经验原封不动地搬到了 AI Agent 领域。

顺带提一嘴它的来历:ACP 是由代码编辑器 Zed(用 Rust 写的那个)的团队发起的,2025 年 8 月 27 日正式发布,第一个吃螃蟹接入进来的是 Google 的 Gemini CLI。在那之前,Zed 只能把 Gemini CLI 塞进内置终端里跑,两边传的全是带颜色控制符的终端字符流,界面根本分不清哪段是正经回复、哪段是在调工具。有了 ACP,这些黑盒字符流才真正变成了结构清晰的消息。到了 2025 年 10 月,JetBrains 也宣布上船,和 Zed 一起推进这个协议。整个协议是完全开源的,采用 Apache 2.0 许可证。


打个比方:前厅和后厨

为了方便理解,整篇文章我们可以把它们想象成一家餐厅:

  • Client 是前厅:负责招呼客人、端菜上桌,遇到拿不准的事替客人拍板。
  • Agent 是后厨:在里屋真正颠勺做菜的厨师。
  • ACP 就是前厅和后厨之间那张格式固定的「传菜单」:客人点了什么菜、后厨实时报进度(「下锅炒了」「这道出锅了」)、后厨拿不准时追问前厅(「这桌客人吃不吃辣?」),直到最后吆喝一声「这桌菜齐了」。

只要后厨按这套传菜单的规矩干活,不管前面换哪家前厅都能无缝对接;反过来,前厅也完全不用操心后厨到底是用煤气灶还是电磁炉炒的菜——对应到技术上,就是不用管背后接的大模型到底是 Claude 还是 DeepSeek。

不过先打个预防针:这个餐厅类比有一个地方是不成立的,而且恰恰是新手最容易踩坑的地方。等会儿聊到「权限控制」的时候,我们再专门把它拎出来讲。


二、先动手:亲自当一回 Client

枯燥的协议规则咱们先放一放,直接上手跑通一遍最直观。

下面我们要写一个极简的 Agent。它甚至不接任何大模型,从头到尾只干一件事:把你发给它的话写进工作目录下的 echo.txt 里,而且动笔写之前,还会乖乖先问你同不同意。咱们姑且叫它「回声 Agent」。


1. 环境准备

不需要装任何第三方依赖包,只要你电脑上有对应语言的运行环境就行:

准备 Python 3.8 或更高版本。新建一个文件夹 echo-agent,在里面建一个 echo_agent.py。

如果你用的是 Windows,记得把后面命令里的 python3 换成 python。


2. 完整代码

先把下面这段代码完整存下来,现在直接当黑盒用就行,等跑出效果了我们再回头拆细节。四种语言的代码我都按同样的逻辑分成了五段:

段 核心职责
① 收发 从 stdin 读一行 JSON,往 stdout 写一行 JSON
② 反问 Agent 反过来向 Client 发请求,并阻塞等待 Client 回答
③ 一轮对话 先回一句话,再申请写文件权限,最后看用户的脸色行事
④ 分发 看收到消息的 method 是什么,决定调哪个函数处理
⑤ 主循环 死循环一直读输入,直到 stdin 关闭才退出进程
python
#!/usr/bin/env python3
"""回声 Agent:一个不接模型的 ACP Agent,把你说的话写进 echo.txt。运行:python3 echo_agent.py"""
import json
import os
import sys

sys.stdin.reconfigure(encoding="utf-8")
sys.stdout.reconfigure(encoding="utf-8")
sessions = {}   # sessionId -> 工作目录
next_id = 0     # Agent 自己发出的请求的编号,和 Client 的编号各数各的

# ① 收发:一行一条 JSON。stdout 只能写协议消息,日志写 stderr
def send(msg):
    line = json.dumps({"jsonrpc": "2.0", **msg}, ensure_ascii=False, separators=(",", ":"))
    print(line, flush=True)

def read():                      # 读到 EOF 返回 None,空行跳过
    for line in sys.stdin:
        if line.strip():
            return json.loads(line)
    return None

def update(session_id, upd):
    send({"method": "session/update", "params": {"sessionId": session_id, "update": upd}})

def say(session_id, text):
    update(session_id, {"sessionUpdate": "agent_message_chunk",
                        "content": {"type": "text", "text": text}})

# ② 反过来问 Client:发请求,然后一直读,直到读到同一个 id 的响应
def ask(method, params):
    global next_id
    my_id = next_id
    next_id += 1
    send({"id": my_id, "method": method, "params": params})
    while True:
        msg = read()
        if msg is None:
            sys.exit(0)
        if msg.get("id") == my_id and "method" not in msg:
            return msg.get("result", {})
        log = json.dumps(msg, ensure_ascii=False, separators=(",", ":"))
        print("等回复时收到,先忽略:" + log, file=sys.stderr)

# ③ 一轮对话:先回话,再申请写文件,按用户的决定办
def prompt(params):
    sid = params["sessionId"]
    text = "".join(b.get("text", "") for b in params["prompt"] if b["type"] == "text")
    path = os.path.join(sessions[sid], "echo.txt")
    say(sid, "收到:" + text + "\n")
    update(sid, {"sessionUpdate": "tool_call", "toolCallId": "call_1", "title": "把这句话写进 echo.txt",
                 "kind": "edit", "status": "pending", "locations": [{"path": path}]})
    answer = ask("session/request_permission", {
        "sessionId": sid,
        "toolCall": {"toolCallId": "call_1"},
        "options": [{"optionId": "allow", "name": "允许", "kind": "allow_once"},
                    {"optionId": "reject", "name": "拒绝", "kind": "reject_once"}]})
    outcome = answer.get("outcome", {})
    if outcome.get("outcome") == "cancelled":
        return {"stopReason": "cancelled"}
    if outcome.get("optionId") == "allow":
        with open(path, "w", encoding="utf-8") as f:
            f.write(text + "\n")
        update(sid, {"sessionUpdate": "tool_call_update", "toolCallId": "call_1", "status": "completed"})
        say(sid, "写好了。")
    else:
        update(sid, {"sessionUpdate": "tool_call_update", "toolCallId": "call_1", "status": "failed"})
        say(sid, "你没同意,我就不写了。")
    return {"stopReason": "end_turn"}

# ④ 分发:按 method 决定怎么回,不认识的返回 None
def handle(msg):
    method, params = msg.get("method"), msg.get("params", {})
    if method == "initialize":
        return {"protocolVersion": 1, "agentCapabilities": {},
                "agentInfo": {"name": "echo-agent", "title": "回声 Agent", "version": "0.1.0"},
                "authMethods": []}
    if method == "session/new":
        sid = "sess_" + str(len(sessions) + 1)
        sessions[sid] = params["cwd"]
        return {"sessionId": sid}
    if method == "session/prompt":
        return prompt(params)
    return None

# ⑤ 主循环:读到 EOF(Client 关掉了 stdin)就退出
while (msg := read()) is not None:
    if "id" not in msg:          # 通知(比如 session/cancel)不用回
        continue
    result = handle(msg)
    if result is None:
        send({"id": msg["id"], "error": {"code": -32601, "message": "Method not found"}})
    else:
        send({"id": msg["id"], "result": result})

3. 启动它,敲进第一行指令

打开终端,进入 echo-agent 文件夹把程序跑起来:

bash
python3 echo_agent.py

回车之后你会发现光标停在那里闪,屏幕上一行字都没有。别慌,这说明它已经正常启动,正在静静地等你(Client)跟它说话。

现在咱们就来扮演一回 Client。把下面这一整行 JSON 复制进终端,直接回车:

json
{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{},"clientInfo":{"name":"hand","version":"0.0.1"}}}

几乎是一瞬间,它会给你吐回一行:

json
{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{},"agentInfo":{"name":"echo-agent","title":"回声 Agent","version":"0.1.0"},"authMethods":[]}}

不管是 Zed 还是 DSH,每次在后台拉起一个 Agent 时,干的第一件事永远都是这个:先报上自己支持的协议版本和能力,再听对方报上家门。这就叫握手。


4. 开启会话,正式聊一句

在继续之前,我们先另外开一个终端窗口,建一个空的测试目录,待会儿让 Agent 往这里面写文件:

bash
mkdir -p /tmp/acp-demo

(如果你用的是 Windows,可以用 mkdir C:\acp-demo 建目录,然后把下面几行 JSON 里的 /tmp/acp-demo 替换成 C:/acp-demo——注意在 JSON 里最好直接用正斜杠 /,省得写反斜杠还得双写转义。)

切回刚才跑着 Agent 的终端,粘贴第二行指令,让它新建一个会话:

json
{"jsonrpc":"2.0","id":1,"method":"session/new","params":{"cwd":"/tmp/acp-demo","mcpServers":[]}}

它会马上给你回一个会话编号(sessionId):

json
{"jsonrpc":"2.0","id":1,"result":{"sessionId":"sess_1"}}

拿到会话编号后,粘贴第三行指令,这次咱们是真正在跟它说话了。在 ACP 协议里,把用户输入的话发给 Agent 用的就是 session/prompt,而用户发出去的这条消息本身也叫 prompt:

json
{"jsonrpc":"2.0","id":2,"method":"session/prompt","params":{"sessionId":"sess_1","prompt":[{"type":"text","text":"你好,ACP"}]}}

回车之后,它会一口气连回三行,然后光标又停住不动了:

json
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"sess_1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"收到:你好,ACP\n"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"sess_1","update":{"sessionUpdate":"tool_call","toolCallId":"call_1","title":"把这句话写进 echo.txt","kind":"edit","status":"pending","locations":[{"path":"/tmp/acp-demo/echo.txt"}]}}}
{"jsonrpc":"2.0","id":0,"method":"session/request_permission","params":{"sessionId":"sess_1","toolCall":{"toolCallId":"call_1"},"options":[{"optionId":"allow","name":"允许","kind":"allow_once"},{"optionId":"reject","name":"拒绝","kind":"reject_once"}]}}

仔细看最后这一行,它跟前面所有的回复长得都不太一样:它居然自己带了个 "method": "session/request_permission"。

这意味着什么?意味着它不是在回答你,而是反过来在问你:「我想写 echo.txt 这个文件,你批不批?」只要你不吭声,它的循环就卡在那里一直死等。

咱们给它批个条子,把下面这行代表「允许」的响应粘进去:

json
{"jsonrpc":"2.0","id":0,"result":{"outcome":{"outcome":"selected","optionId":"allow"}}}

收到批复后,它立马利索地吐出最后三行:

json
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"sess_1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_1","status":"completed"}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"sess_1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"写好了。"}}}}
{"jsonrpc":"2.0","id":2,"result":{"stopReason":"end_turn"}}

现在你去打开 /tmp/acp-demo/echo.txt 看看,里面赫然躺着刚才那句「你好,ACP」。

试完之后,按 Ctrl+D(Windows 上是按 Ctrl+Z 再回车)关掉标准输入流,Agent 读到 EOF 就会自己体面地退出进程。

有兴趣的话你可以再试一次「拒绝」的分支:把 /tmp/acp-demo 清空,重新跑起 Agent,把前三条指令再粘一遍;等到它问你要权限的时候,把回复里的 "optionId":"allow" 改成 "optionId":"reject"。你会看到这次目录下根本不会生成 echo.txt,Agent 吐回来的最后三行也会变成这样:

json
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"sess_1","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_1","status":"failed"}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"sess_1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"你没同意,我就不写了。"}}}}
{"jsonrpc":"2.0","id":2,"result":{"stopReason":"end_turn"}}

把刚才咱们手敲的这来回 12 行消息画成一张时序图,其实就是下面这个过程:

前厅 Client 递出任务,后厨 Agent 报进度、反问写入权限,最后交回结果与本轮结束的响应

别看咱们刚才在终端里粘 JSON 粘得像个苦力,其实 dsh-ccgui 在后台干的跟咱们一模一样,只不过它是用代码自动帮你收发这些行而已。接下来,我们就把这 12 行消息掰开揉碎了挨个看一遍。


三、三种消息,两个方向


1. 铁律:一行就是一条消息

在 ACP 里,Client 会把 Agent 当成自己的子进程拉起来,然后两边的通信全靠标准输入输出:

  • Client 往 Agent 的 stdin 里写数据,Agent 往自己的 stdout 里吐数据;
  • 一条完整的消息必须严格占一行,以换行符 \n 结尾,消息体内部绝不能出现未转义的换行;
  • 编码统一用 UTF-8;
  • Agent 的 stdout 是协议专用通道,只能写纯粹的协议 JSON。如果你自己的代码想打调试日志,必须写到 stderr 里去(Client 可能会收集 stderr 报错,也可能会直接忽略)。

这套基于标准输入输出的通信方式,叫 stdio 传输,也是目前 ACP 唯一正式定稿的传输方式(针对远程 Agent 的 HTTP 和 WebSocket 传输目前还在起草阶段)。

这几条规矩里,最后一条最容易让人翻车。很多人写代码习惯随手 print("debug: hello") 或者 console.log(...),这等于直接往 stdout 掺了沙子。我专门测过:dsh-ccgui 底层用的官方 SDK(1.4.0 版本)脾气还算好,读到这种非 JSON 的杂音行会先把它丢掉、打一条错误日志,然后接着往下跑;但协议规范里可没要求 Client 必须这么宽容,换个严格点的 Client 当场就给你断开连接了。所以你会看到,前面回声 Agent 里凡是打日志的地方,全都老老实实写进了 stderr。


2. 消息总共就三种长相

刚才那 12 行文本,每一行其实都是标准的 JSON-RPC 2.0 消息。JSON-RPC 是早在 2010 年就定下来的老前辈了,专门用来规范「怎么用 JSON 调远程函数」。它把天下所有的消息只分成三类:

  • 请求(Request):同时带有 method(要对方干啥)和 id(小票编号)。收到请求的一方必须给个回音。
  • 响应(Response):只有 id,没有 method。内容里要么装着成功的 result,要么装着报错的 error。
  • 通知(Notification):只有 method,没有 id。发出去就完事了,对方不需要(也不能)回消息。

所以你肉眼看日志的时候,根本不用背方法名,光扫一眼有没有 method 和 id 这两个字段,就能把消息的性质分得明明白白:

小黑分拣三种消息:请求有 method 和 id,通知只有 method,响应带 id 而没有 method

拿咱们刚才敲的那 12 行对号入座一下:你发出去的 initialize、session/new、session/prompt 全是请求;Agent 给它们回的确认都是响应;而 Agent 在干活中途吐出来的那四条 session/update,全都没有 id,属于标准的通知——所以当时你一条都没回它。


3. 双向请求,各自记账

细心的你刚才可能发现了一个怪事:在上面那 12 行记录里,居然有两条消息的 id 都等于 0——一条是你最开始发的 initialize,另一条是 Agent 中途反问你的 session/request_permission。两边都用 0 号,难道不会串台搞混吗?

答案是真不会。因为在 JSON-RPC 里,Client 和 Agent 各自拿一个小本本,只给自己发出去的请求编号,两套编号是完全独立的。

本文是会员内容

全文 13,210 字,已试读约 29%。开通专业版可阅读全文,并解锁全部官方课程与后续更新。

开通专业版

支持 7 天无理由退款 · 早鸟价 99 元/年(售完恢复365/年)

课程中涉及的第三方项目,其著作权与商标归各自权利人所有;引用处的署名与许可见《开源与来源说明》。

上一节:拆解 DSH-CCGUI 下已经是最后一节了