跳到主要内容

内容阅读

ACP 协议与 acp-agent:dsh 的 agent 通话标准

ACP 协议与 acp-agent:dsh 的 agent 通话标准

MCP 解决 agent 怎么接外部工具,ACP 解决 agent 怎么被外部程序驱动。dsh 在 ACP 里两侧都站:服务端把自己暴露成 ACP 服务器,任何兼容客户端都能创建会话、发提示、收结果;客户端一侧又能把外部 agent 当作子 agent 拉起。同一条 JSON-RPC 线,让 agent 变成可编程的自动化单元。 服务端 @deepseek-ai/dsh-acp 是一个刻意做窄的自动化传输适配器:只输出已提交的完整答案,不做逐 token 流;权限只做一次性决策,不持久化授权;关闭时精确归属、不留孤儿。窄不是没做完,是给程序消费者的正确形状。

两个协议,两个方向

MCP 和 ACP 名字像,范畴不同,混着读会越来越糊涂,先分干净。

MCP 的方向是 agent 向外要能力。一个 MCP 服务器暴露工具、资源、提示模板,agent 作为客户端去消费,目标是让工具生态可复用。ACP 的方向反过来,是外部程序向 agent 发任务、收结果。一个 ACP 服务器封装了一个或多个 agent,外部客户端,编辑器、另一个 agent、CI 脚本,通过协议创建会话、发提示、拿回复,目标是让 agent 本身变成可调用的服务。

ACP 由 Zed 编辑器团队在 2025 年推出,社区叫它"AI 编码 agent 的 LSP"。这个类比精确:LSP 让任何语言服务器接任何编辑器,ACP 让任何编码 agent 接任何客户端,两者都是把"一边的生态"从"另一边的私有接口"里解放出来的中间层协议。形态上 ACP 用 JSON-RPC 2.0,跑在 stdio 上,消息按换行分帧。

拿外设和服务打比方:MCP 是 agent 的 USB 接口,管外设标准;ACP 是 agent 的 HTTP API,管服务标准。dsh 两个都实现了,这篇讲第二个。

dsh 两侧都站

理解这套实现,先看清它站在协议的哪一侧。答案是两侧。

服务端是 @deepseek-ai/dsh-acpdsh 把自己暴露为一个 ACP 服务器,外部客户端连上来,走完整的会话生命周期。这让 dsh 的 agent 能被任何 ACP 兼容的客户端驱动,不限于自家的 Web UI。客户端是另一个包:dsh 的 agent 可以通过子 agent 机制,把一个外部 ACP 服务器拉起来当自己的子 agent,任务交过去,结果收回来。

两侧合起来意味着 agent 可以嵌套:一个 dsh agent 通过 ACP 调用另一个 dsh agent,或者任何 ACP 兼容的 agent,作为自己的子 agent,子 agent 跑在独立进程或沙箱里。跨过这条线的东西很克制:父 agent 交出去的是一段文本任务,收回来的是提交过的结果和一次性的权限决策,双方各自的会话日志、插件树、沙箱策略互不越界。换句话说,协议既是协作的桥,也是隔离的墙,一个 agent 崩了或跑飞了,损失被进程边界挡在那一边。这是多 agent 协作的基础设施,协议线是同一条。

这篇聚焦服务端。它自己给自己的定性是全文的纲:一个仅面向自动化的 ACP 服务器,跑在 JSON-RPC stdio 上。不是 UI 集成,不是能力接缝,是一个传输适配器,职责是把 agent 的会话能力翻译成协议消息。

不暴露的东西和暴露的东西一样有信息量:编辑器导航、对话回放、命令面板、模式切换、配置选择、交互式表单、推理过程、计划、标题、工具呈现,统统不在。这些都是 Web host 和客户端模块的活。一个自动化端点长成聊天界面的样子,才是跑偏。

配置只有两个可选字段:provider 和 model,给每个新建的 agent 定初始路由。两个都可以不填,留给别的请求监听者补上目标;但可运行的组合里必须都有。可选项的存在是把组合的自由度留给部署,适配器自己不做默认决定。

还有一条硬纪律:stdout 被协议帧独占,诊断信息只能走 stderr。stdio 传输里混进一行日志就是一帧坏协议报文,这不是风格要求,是正确性要求。

会话的一生

ACP 的中心概念是会话,外部客户端用它操作 agent 的完整生命周期。按一个会话从生到死的顺序过一遍方法。

握手在 initialize。协商协议版本,声明能力。声明的诚实度值得看:提示能力默认只有基线文本;只有当部署挂了持久附件存储、且配置的确切 provider 和 model 显式支持图片输入时,才声明图片能力;音频和嵌入上下文恒为不声明。会话、编辑器、终端、文件系统、MCP 能力一概不声明,认证方法也不声明,所以随后的认证步骤是个空操作。这个握手把边界说在前头:我做的是自动化对话,交互式的能力别来找我。

session/new 创建一个全新的 agent,需要一个绝对路径的工作目录。额外的目录列表和 MCP 服务器,空值可以收,非空直接拒。每个 ACP 会话拿到的都是一个干净的 agent,钉在一个明确的工作目录上,不带私货。

session/prompt 发提示。内容的规则延续握手的声明:有序的文本块保留顺序,相邻文本拼接;resource 链接渲染成方括号的文本引用,不真去取内容;图片要先过整批验证,在保存任何东西之前重新核对会话当前的路由还支持图片,全部通过才逐张提交、赶在用户事件之前落盘,行内的 base64 在准入后即丢弃。音频、嵌入资源、畸形或空输入、未声明图片能力时的图片,全部拒绝。

并发规则是一条:每个会话同一时间只允许一个在途请求。调用会阻塞,等的不只是一个 turn,而是整个 agent 空闲加上有序输出投递完成。这个等待语义直接引出下一节的问题:一个 prompt 对应多个 turn 时,结果怎么算。

取消是 session/cancel。有在途 prompt 时,取消目标 agent,等着它静默,pending 的 prompt 结算为取消,不发迟到的用户消息;没有在途 prompt 时,取消的是自主工作;未知的会话 id 是空操作。它从不取消、也从不等待不相关的 agent 工作,归属精确。

通知是 session/update:每个已提交的 assistant 消息里,每个非空的文本或图片块发一个消息块通知,保持顺序。注意原料是已提交的消息,不是原始增量。

stopReason:一个 prompt 怎么结算

dsh 的一个 prompt 可能触发一串 turn:模型思考、调工具、再思考。session/prompt 的返回要等这整段旅程结束,那停止原因怎么报,有一套精确的结算规则,值得单独拆开。

先划责任区间。每个 prompt 拥有一个区间:从它的消息进收件箱开始,到 agent 完全静默结束。区间之外的失败不归这个 prompt 管,比如消息进收件箱之前别的自主工作挂了,不往 prompt 头上算。这个划法防止了"谁在旁边崩了都算我失败"的讹账。

结算按优先级排序:显式取消最优先,其次输出投递失败,再次区间范围内的整体 agent 失败,最后才是关联的那个 turn 的自然结尾。正常静默报正常结束;显式取消、连接销毁、以及准入被丢弃的 prompt(一个没开过 turn 的空槽)报取消。

两个容易报错的边角被明确钉死。token 上限导致的 turn 结束,报正常结束而不是某个特殊停止原因,因为从自动化消费者视角,模型说完了就是说完了,原因细节在日志里。关联的模型错误同样等到静默边界才让 prompt 以错误收场,不会中途炸出去。

这套规则服务的对象是写客户端的程序员。你的脚本调一次 prompt,拿到一个语义明确的终态:要么正常结束,要么取消,要么错误,不用处理"流到一半怎么算"的中间态。中间态存在,但被挡在传输线之外。

放一条具体时间线进去。脚本十点整发了一个 prompt,agent 随即开了三个 turn:第一个 turn 读文件,第二个 turn 改代码,第三个 turn 试着跑更宽沙箱的命令。十点零二分,脚本因为流水线超时发了取消。此刻结算开始:显式取消优先级最高,目标 agent 被停,第三个 turn 中断,整个区间静默后 prompt 返回,停止原因是取消。没有迟到的用户消息被发布,第三个 turn 已经做了一半的文件改动留在磁盘上,但会话日志里的记录是自洽的,事后重放能看懂发生了什么。换成另一个剧本,十点零二分不是脚本取消而是模型报错,结算走到优先级的最后一档,同样等静默边界,prompt 以错误收场。两个剧本里,客户端代码的形状是一样的:调、等、拿终态、分支处理。

再补一个并发维度的推论。每会话一个在途请求,意味着单个会话内的对话是严格串行的,想在多个项目上并行,就开多个会话:一个连接可以拥有多个会话,每个会话有自己的提示槽、工作区、取消路径和销毁器。四个项目并行修复,就是四次 session/new、四个不同 cwd、四条并行的 prompt 等待。会话之间互不牵连,一个会话出错不影响另外三个的结算,清理时也是各归各的销毁器。并发模型是一目了然的进程内多路复用,客户端不需要锁,只需要别在同一个会话上发第二个请求。

committed-only:为什么不做逐 token 流

这是这套实现最反直觉的决策,也是理解它定位的钥匙。

聊天 UI 的默认体验是逐 token 流式,文字一个个蹦出来。这里的输出只在消息提交之后才发:每个提交的 assistant 消息按块通知,原始增量和非消息事件一概不上线。设计文档的原话把交易说得很直白:故意用逐 token 的延迟,换一个干净的自动化结果。

为什么程序消费者要的是这个?因为程序要的是"这段文字是最终答案的一部分",不是"模型此刻正在想这几个字"。如果未提交的 provider 块和重试尝试漏出去,客户端拿到的就是碎片化、可能自相矛盾的内容:模型走了一条路又回退,前一段话和后一段话互相打架,你的解析代码得替模型的犹豫买单。committed-only 保证收到的每一段都是模型确认过的,可以直接进后续处理,部分文本和部分图片都不会泄漏。

图片的往返还有一道完整的闭环。进的时候整批验证、逐张提交、base64 即弃;出的时候从附件存储重新读取、校验完整性,再以内联 base64 交付。一个已提交的图片丢了或坏了,prompt 以失败收场,不发占位图。每会话的投递串行化,附件的异步读取不会把消息顺序搅乱。

对延迟敏感的读者会问代价。客户端要等消息提交才能看到内容,对话体验确实不如流式。但自动化场景的账不是这么算的:脚本在乎的是拿到手的东西能不能直接用,和全程可追溯,中间过程在会话日志里一个不少,想观察随时能观察,只是不实时上协议线。

顺带一个对成本敏感的细节:会话的 KV 缓存保持只追加,新的用户消息跟在可复用的请求前缀之后。也就是说通过 ACP 驱动的长会话,前缀缓存照样吃到,协议层的翻译没有把缓存结构打散。

线上没有的东西,去处要说清楚,不然"committed-only"听起来像信息丢失。推理过程、工具调用及其结果、计划、用量,全部在会话日志里,一条不少;示例组合还把每个会话持久化成 JSONL 文件,崩了、超时了、三个月后想审计了,拿日志重放就能还原当时发生了什么。线上传输和持久记录是两份不同的投影:前者给程序的决策循环,够用就好;后者给审计和排查,无损为准。选哪份看你的角色,写客户端看线上的终态,写事后分析看日志的全量。两条通道各司其职,比把全量信息挤上一根 stdio 管子健康得多。

权限:一次性决策

模型在沙箱里干活,遇到需要更宽访问的重试时,服务端向客户端发起一次权限请求:允许这一次,还是拒绝这一次,就两个选项。客户端可以自动回答,这本来就是给程序留的口子。关掉对话框、不回答、答案不可用,一律按拒绝收场,故障时关闸。

这个模型的克制在"一次"两个字上。客户端的一次同意不会变成持久授权,选择只作用于那一次重试,结果通过正常的工具结果路径记录,模型看到的只是工具结果,权限选择本身、会话 id、各种元数据都不进模型请求。服务端不暴露权限选择器,也不持久化任何客户端策略。

安全含义想清楚会出汗:自动化客户端天然是无人盯的,脚本被攻破或配置写错,都可能让一个错误的"允许"发出去。一次性决策把这个最坏情况控制在单次重试的范围内,下一次要提权,再问一次,再决策一次。持久授权换来的便利,在无人值守场景里就是把安全边界一次性交出去。

优雅关闭:精确归属,不留孤儿

连接断开时要清理这个连接创建的所有 agent,这件事比听起来复杂,因为子 agent 可能比启动它的 turn 活得更久,它们是可以续命的。

两条触发路径,客户端断连和运行时销毁,共用同一个记忆化的清理流程,不会重复执行。流程分四步走:先关门,拒绝新的会话和提示;再结算,把所有在途 prompt 结算为取消,把 agent 活动、准入和有序投递全部带到静默;然后排空,只排空挂在这个连接拥有的顶层 agent 下面的可续命后代,从子到父逐层销毁,不让任何子 agent 残留在一个已经释放的运行时上;最后销毁顶层 agent,并行执行,等所有结果返回,任何失败都收集起来一起报,不让一个失败掩盖其他失败。

归属的精确性靠身份检查兜底:每条会话事件和权限请求都必须精确匹配这个连接拥有的那个 agent 实例,用带品牌标记的会话 id 做校验,同 id 的冒充者不认。一个连接可以拥有多个会话,每个会话有独立的提示槽、工作区、取消路径和销毁器,互不牵连。

还有一条对共享部署很重要:多个前端可以共享同一个运行时上下文,这个连接清理时,别的前端旗下的可续命子代理森林原封不动。反过来,一个只跑 ACP 的进程重载之后不留任何孤儿 agent。清理的边界画在"这个连接拥有的"上,不多销毁一个,也不漏掉一个。

acp-agent 示例:一行命令的可运行组合

examples/acp-agent 把上面的适配器组成一个完整的、可运行的 ACP 服务器。两条命令:pnpm run demo:acp 拉起标准组合,pnpm run demo:code-mode 换成同协议但走 Code Mode 工具传输的变体。都需要 DeepSeek 的 API key,从仓库根的 .env 或环境变量读。

组合装载的东西列出来能看出它离生产形态有多近:ACP 应用、DeepSeek 模型适配器、沙箱化的 bash 和文件系统栈、一次性审批策略、上下文压缩、子 agent、工作流、钩子、派生的会话查询索引、重复防护。可选的 overlay 还能叠上会话查询、文件系统溢出存储、Code Mode、网页抓取。每个 session/new 创建全新 agent,会话持久化到 JSONL 文件,崩了可查。

两个工程细节容易被漏掉,漏掉就会踩坑。一是 stdout 的纯净化:demo 不装任何 stdout 日志器,所有诊断走 stderr,因为 stdout 上是换行分隔的 ACP JSON-RPC 帧,你往组合里加自己的叶子组件时,日志也必须走 stderr。二是会话工作区隔离:每个会话带独立的绝对工作目录,沙箱化的写操作针对那个目录解析 workspace-write 策略,所以并发会话可以各用各的项目根互不干扰,平台临时目录作为共享的可写暂存区不变。部署层面用环境变量在 workspace-write 和 danger-full-access 之间选权限档位。

权限升级的完整闭环在这个示例里也能看到:workspace-write 档位下,模型的重试想要更宽的沙箱访问,触发一次权限请求,客户端程序化地选允许一次或拒绝一次,关窗或不答按拒绝算,结果只作用于那次重试、走正常工具结果和审计路径。

走一遍:一个 CI 脚本的完整往返

把前面的规则串进一个具体场景。你有一个 CI 脚本,想在流水线里让 agent 修一个失败测试。

脚本把 acp-agent 组合当子进程拉起,stdin 和 stdout 各接一根管子。第一步发握手,客户端声明自己,服务端协商版本、声明能力:文本提示,有持久附件存储且路由支持图片的话还有图片,别的不声张。第二步创建会话,给一个绝对路径指到仓库检出目录,额外目录和 MCP 服务器都留空。此刻一个全新 agent 挂在了那个目录上,沙箱策略按部署档位就位。

第三步发提示:"跑一下失败的测试,修复它,别动测试本身的断言"。请求阻塞。agent 内部开始工作:读文件、跑命令、改代码,每个提交的 assistant 消息以消息块通知到达脚本,脚本看到的是一段段确认过的文本,不是 token 雨。中途模型想用更宽的文件访问重试一次,脚本收到权限请求,按白名单逻辑回了允许一次,这一次重试放行,工具结果照常进审计。几分钟后 agent 静默,prompt 返回,停止原因是正常结束,脚本报数、提交代码。

异常分支同样明确。跑到一半流水线超时,脚本发取消,agent 被停,prompt 结算为取消,没有迟到的用户消息污染会话日志。脚本退出、管道断开,清理流程四步走完,这个连接旗下的 agent 连同可续命的后代全部销毁,JSONL 日志留在磁盘上供事后追查。整个过程没有任何一步需要人盯屏幕,这就是 automation-only 的完整含义。

选哪条自动化入口

dsh 给外部程序留了不止一条路,ACP 是其中之一,选之前把路口摆清楚。

Web UI 是给人看的入口。浏览器里开对话、看流式输出、点权限按钮,交互体验最全,但程序驱动它要靠模拟人操作,脆弱且不可审计,任何严肃的自动化都不会走这条路。

ACP 是进程级的标准入口。你的程序把 agent 当子进程拉起,用协议消息对话,stdio 两根管子,没有网络端口要管,认证、防火墙、服务发现全部省掉。代价是生命周期绑进程:连接断开即清理,客户端和服务器要活在一起。这适合编排器拉起 agent、CI 里跑一段、编辑器内嵌 agent 这类"宿主拥有进程"的场景,也是 Zed 做它的初衷。

什么时候不该选 ACP?你要的是长驻的服务、跨机器的访问、多客户端共享的会话状态,那你要的是网络服务形态,进程级 stdio 线不适合,得自己在外面架一层。你要的是人机混合的交互,中途要人看要人点,那 Web host 才有那些部件。你要的是批量任务队列,天然适合每次任务一个干净会话的模式,ACP 反而很合身。

还有一层选型考虑是消费者身份。ACP 的客户端可以是另一个 agent,父 agent 拉起子 agent、交任务、收结果,这条嵌套路径和服务端是同一套协议。如果你的场景是 agent 编排而不是脚本编排,入口还是这个入口,只是调用方从 while 循环换成了另一个模型。

权衡

窄是刻意的,但窄意味着有边界,逐条说清。

只支持全新会话。load、list、resume、delete、fork 都不支持,每次都是 session/new 造新 agent。想接着上次的会话干,当前不行,得等这些方法进实现。对"每次任务一个干净会话"的自动化模式,这个限制几乎无感;对需要续话的工作流,是硬墙。绕法存在但别扭:客户端自己记下上次会话的要点,在新会话的 prompt 里当作上下文重述,等于用协议外的手段手工搬运状态,成本随对话长度线性上涨。

提示内容收窄。音频、嵌入资源拒收,非空的额外目录和 MCP 服务器拒收,图片只有四种光栅格式且要部署条件齐全,resource 链接拍平成文本引用而不是取内容。多模态和外部资源的能力,要通过别的路径补。

线上只有已提交的答案。实时进度、推理过程、工具活动、计划、标题、用量统计统统不在传输里。要这些,去会话日志或遥测里拿。写客户端时别指望在线上做进度条。

生命周期是连接级的。一个连接断开释放它名下所有会话,单个会话的关闭没有实现。会话数量多的客户端要自己管理连接的粒度。

这些边界共同的指向是同一个判断:它是自动化传输层,不是全能 UI。交互式渲染、人类提问、会话管理界面,属于 Web host 和客户端模块的地盘。把它当 agent 的自动化 API 用,每条限制都顺理成章;拿它当聊天界面用,每条都是缺陷。

结论

dsh 在 ACP 里两侧都站,这篇拆的是服务端:一个刻意做窄的自动化传输适配器。会话生命周期翻译成六七个协议方法,握手只声明真有的能力;prompt 的结算有精确的责任区间和优先级,程序拿到的是语义干净的终态;输出只在消息提交后上线,用延迟换确定性;权限只做一次性决策,故障时关闸;清理精确到连接的归属,不留孤儿。acp-agent 示例把这些组成一行命令可跑的组合,配好沙箱、审批、压缩和持久化。评估这类自动化端点,看它把不确定性挡在了线外多少,比数它支持几个方法有用得多。

延伸阅读

上一篇:MCP 协议在 dsh 中的位置:一个通用客户端,一份记忆服务器接入手册 下一篇:web-cordis:dsh 里会改自己插件树的 agent


GitHub 原文:33-acp-protocol-acp-agent.md

评论

0
登录后可以参与评论和讨论。

EMPTY

还没有评论

欢迎留下第一条评论,帮助这篇内容更快形成讨论。