不写 Agent Loop:用 ACP 快速搭一个能干活的 Agent

为了做一个 Agent,先写半个 Agent 框架,这事多少有点本末倒置。

之前做飞书助手时,我一开始想的是常规路线:接模型 API、定义 Function Calling、实现 Agent Loop,再把文件、Shell 和飞书能力一个个封装成工具。飞书这部分其实已经有现成的 lark-cli,但如果从模型 API 起步,我仍要自己定义工具 schema、执行 CLI、回填结果并维护循环。列完待办之后才发现,真正的业务代码还没写,Agent 运行时倒是快搭出一套了。

但我电脑里本来就有 Cursor Agent。它已经会调模型、读写文件和跑命令,也能使用 Rules、Skills 和 MCP。飞书能力则不需要再做一个 MCP Server:Agent 直接通过 Shell 调用 lark-cli,后者负责访问飞书 OpenAPI。缺的只是一种办法,让我的 Python 程序像编辑器一样去驱动这个 Agent。

ACP 正好补上了这一层。这篇不展开讲一长串协议史,只做一件事:用 ACP 把现成的 Agent 能力接进自己的程序,先跑起来,再看怎么扩成一个真正能干活的 Agent。

轻量业务程序通过 ACP 双向连接具备终端、文件和工具能力的 Agent 运行时

在 ACP 之前,我们是怎么接 AI 的

在写 ACP Client 之前,先花一点篇幅把前面的几块拼图摆好。Chat Completions、Responses API、MCP 和 ACP 经常一起出现,但它们解决的并不是同一个问题。

Chat Completions:模型只负责回答

最直接的接法是 Chat Completions API:程序组装 messages,模型返回文字;需要多轮对话,就由程序保存历史并在下一次请求时继续带上。

模型后来也能返回 tool_calls,告诉程序「我想调用哪个函数、参数是什么」。但模型只负责提出调用意图,真正执行函数、回填结果和决定是否继续请求的,仍然是我们的程序。

这里特意写 Chat Completions,是因为它和更早的 Completions API 不是同一个接口:前者接收 messages,后者主要接收 prompt。很多文章把两者都简称为 Completion,读代码时很容易串台。

Responses API:少写一些状态和工具胶水

Responses API 把文本、图片、工具调用等结果放进统一的 response 结构里,也提供 Web Search、File Search、Code Interpreter 等内置工具。多轮对话可以用 previous_response_id 串起来,不必每次都重新传一份完整的 messages

不过,这不等于模型有了无限记忆。关联的历史仍会占用上下文和 token,也仍然受上下文窗口限制。采用自定义函数时,业务程序依旧要执行工具并把结果交回模型,Agent Loop 并没有完全消失。

MCP:把 Agent 接工具的方式统一起来

当每个客户端都要单独接文件、数据库和内部 API 时,工具集成很快会变成另一堆胶水。MCP(Model Context Protocol)给这层定义了统一接口,让 MCP Server 可以暴露 toolsresourcesprompts,支持 MCP 的 Host 再通过 MCP Client 使用这些能力。

MCP 解决的是「Agent 怎么接外部能力」,不负责替业务程序驱动一个完整 Agent。任务怎么规划、工具何时调用、失败后是否重试,仍然需要 Agent 运行时或自己的编排代码处理。

走到这里,模型能回答,工具也能标准化接入,但我的 Python 程序如果想直接使用一个完整 Agent,仍然缺少 Client 和 Agent 之间的接口。ACP 补的就是这一层。

自己接模型,麻烦主要不在那次 HTTP 请求

只做一次问答,调用模型 API 很简单。等模型开始调工具,事情就变成了一个循环:

flowchart LR P[业务程序] -->|messages / prompt| M[模型] M -->|tool call| P P -->|执行工具| T[文件 / Shell / 业务 API] T -->|结果| P P -->|继续请求| M

这个循环就是 Agent Loop。真正费时间的地方都藏在循环周围:

  • 保存和裁剪对话上下文;
  • 注册工具、校验参数、回填结果;
  • 处理重试、超时和中途取消;
  • 决定敏感工具要不要人工批准;
  • 把模型的流式输出和工具状态交给上层界面。

这些工作并非没有价值。金融交易、发布上线、数据删除之类的流程,需要确定性和完整审计,自己掌握 Agent Loop 更稳。

但我当时想做的是个人工具和飞书助手,核心需求是让 Agent 读项目、查资料、跑脚本。为了这些能力重新造一遍运行时,投入有点重。ACP 的思路是把分工换一下:业务程序只当 Client,规划和工具循环交给已经成熟的 Agent。

ACP 到底接走了哪部分

ACP 全称 Agent Client Protocol,约定了 Client 和 Agent 怎么通信。这里的 Client 可以是编辑器,也可以是 Python 服务、聊天机器人或桌面工具;Agent 则是 Cursor 这类已经具备模型和工具能力的运行时。

我更愿意把这层关系理解成一个木偶师舞台:图左上角的控制台是自己的业务程序,中间三个发光核心代表不同的 Coding Agent,底部机械结构则是各家 Agent 运行时长期积累的 Harness。自己的程序只牵住 ACP 这根控制杆,用统一方式给 Coding Agent 下任务、维持会话、处理权限并接收结果;具体怎么规划和调用能力,仍由 Coding Agent 自己决定。

业务程序像木偶师一样通过 ACP 操作多个 Coding Agent,各 Agent 自主使用 Tools、Skills、MCP,并复用底层 Harness Engineering

图里的 Harness 不是 ACP 协议规定的某个模块,而是业界 Coding Agent 背后那套长期打磨的工程底座:上下文管理、任务规划、工具循环、权限确认、会话恢复、错误处理和可观测性。它们才是一个 Agent 从「能调模型」走到「能稳定干活」最费功夫的部分。想继续拆这层幕后工程,可以看 Harness Engineering 的介绍与编程实践

所以 ACP 带来的不只是少写一次 API 调用。我的程序通过同一套 Client 接口驱动成熟 Coding Agent,Agent 再去使用自己的 Tools、Skills、CLI 和 MCP,相当于把这些产品已经积累的 Harness Engineering 一起复用了。木偶师只表达意图和边界,并不微操木偶的每一个关节。

这里要把「通用能力图」和后面的飞书案例分开看。上图里的 MCP 表示 Coding Agent 可以接入的一类标准化外部能力,不代表这套飞书助手用 MCP 操作飞书。实际项目选的是更直接的一条链:Cursor Agent 通过 Shell 执行 lark-cli,再由 lark-cli 调用飞书 OpenAPI。

接入成熟 Coding Agent 后,我自己的程序仍要负责业务入口、身份、消息路由和安全策略,模型调用、任务规划、工具循环和内部状态则由 Agent 运行时承担。ACP 做的事情更克制:把 Client 与 Agent 之间的交互接口统一起来。

这和普通的 headless CLI 也有区别。很多 AI CLI 都能接收 prompt、输出 JSON,但参数、事件类型和会话恢复方式各有一套。ACP 把初始化、能力协商、session、流式更新、权限请求和取消这些生命周期统一成了协议。

要注意,能从 stdin 读写 JSON 不代表支持 ACP。具体工具能不能接,要看它是否明确实现了 ACP。

已有多少 Agent 支持 ACP

ACP 由 Anysphere(Cursor 背后的公司)发起,以 JSON-RPC 2.0 为基础,定位类似 LSP 之于编程语言——不过 LSP 标准化的是编辑器与语言服务之间的通信,ACP 标准化的则是 Client 与 Coding Agent 之间的通信。

目前已有 40 多个 Agent 实现了 ACP,覆盖主流 Coding Agent 生态:Cursor、Claude Code、Gemini CLI、GitHub Copilot、Codex CLI、Augment Code、Cline、OpenCode、Kiro CLI、Qwen Code 等。这意味着选择 ACP 作为接口层,业务代码并不会被绑死在某一家 Agent 上。

ACP 的核心场景是 IDE 与 Coding Agent 的集成——VS Code、JetBrains、Zed、Neovim 和 Qt Creator 都通过 ACP 接入 Agent。但协议本身不限于 IDE,IM 机器人(飞书、Slack、Telegram)、桌面应用、CLI 框架(LangChain、Mastra、Jupyter)都可以作为 Client。

为什么用 ACP:核心价值在业务逻辑

理解协议是一回事,决定用它又是另一回事。这一节说说选择 ACP 的实际理由。

工作量分布的真相

做飞书助手之前我盘过工作量。如果自己写 Agent,大概的精力分配是这样的:

部分占比内容
Agent 能力~90%Agent Loop、工具系统、上下文管理、流式输出、崩溃恢复
飞书业务逻辑~10%消息分流、上下文传递、权限控制、回复格式

这还没算后期的维护成本:内存泄漏要修 Loop,竞态条件要调并发,状态不一致要改恢复逻辑。框架修来修去,真正的飞书业务一行没动。

用 ACP 之后,分工变成了:

部分占比内容
Agent 能力0%全部交给 Coding Agent 的成熟 Harness
飞书业务逻辑100%消息分流、上下文传递、权限控制、回复格式

Agent 能力全部复用,精力 100% 集中在飞书业务上。实际项目最终只用了大约 600 行 Python 加上若干 Markdown 文档,就跑起了一个能读写飞书文档、查日程、建任务、操作审批的完整飞书助手。

声明式 vs 命令式

更深一层的变化是编程范式的切换。传统做法是命令式的:写 Python 代码定义工具、写代码处理工具调用逻辑、写代码管理权限控制、写代码维护上下文传递。

ACP 方式则是声明式的:

  • AGENTS.md — 定义 Agent 的角色、能力边界和安全约束。Agent 启动时自动读取,理解「我是谁、能做什么、不能做什么」。
  • SKILL.md — 声明式定义工作流。例如写一个 deploy-ai-project.skill.md,里面声明触发词、前置条件、执行步骤和回复格式。Agent 读到这个文件后就具备了执行该工作流的能力,不需要写一行 Python。
  • agent.yaml — 配置运行时行为,比如 builtin_replies(ping 消息直接回 pong,不走模型)、白名单、ACP 权限设置。

新增功能只需要加一个 SKILL.md,不用改代码。这种「写文档而不是写代码」的方式,对非技术协作者也非常友好——产品经理或运维同事也能修改 Agent 的行为规则。

flowchart TB A[启动 Agent] --> B[自动读取 AGENTS.md] B --> C[理解角色与约束] D[用户消息] --> E{匹配 SKILL?} E -->|是| F[执行 SKILL 步骤] E -->|否| G[Agent 推理决策] C -.->|上下文| G

先跑通最小链路

下面用 Cursor CLI 做 Agent。先安装并登录,然后确认 ACP 模式能启动:

agent login
agent acp --help

agent acp 启动后不会出现聊天界面。它在 stdin 等待 JSON-RPC 请求,再把 response 和 notification 一行一条写到 stdout

一次完整提问要经过这几步:

sequenceDiagram participant C as Python Client participant A as Cursor Agent C->>A: initialize A-->>C: capabilities C->>A: authenticate(cursor_login) C->>A: session/new A-->>C: sessionId C->>A: session/prompt A-->>C: session/update(流式内容) A-->>C: session/prompt response(stopReason)

这几个方法里,initialize 建立协议能力,session/new 创建对话,session/prompt 才真正把任务交给 Agent。Agent 的文字会通过 session/update 持续返回,收到对应的 session/prompt response 才表示这一轮结束。

一个只依赖标准库的 ACP Client

下面这个例子尽量压到最小,只做一轮只读问答。它会把 session 切到 Cursor 的 ask mode,不执行写文件或 Shell 等工具。代码虽然不长,但走的是真实 ACP 流程,不是把几个自定义的 messagetextdone 事件拼成示意代码。

还有一个边界要先说:这个版本是单进程、单线程、一次只跑一个 turn 的教学示例。它适合验证协议链路,不适合直接拿去同时处理多个群聊。

import json
import subprocess
from pathlib import Path


class CursorAgent:
    def __init__(self, workdir="."):
        self.workdir = Path(workdir).resolve()
        self.proc = subprocess.Popen(
            ["agent", "acp"],
            stdin=subprocess.PIPE,
            stdout=subprocess.PIPE,
            stderr=subprocess.DEVNULL,
            cwd=self.workdir,
            text=True,
            bufsize=1,
        )
        self.next_id = 1
        self.chunks = []
        self.active_session_id = None

    def send(self, message):
        self.proc.stdin.write(json.dumps(message) + "\n")
        self.proc.stdin.flush()

    def request(self, method, params):
        request_id = self.next_id
        self.next_id += 1
        message = {
            "jsonrpc": "2.0",
            "id": request_id,
            "method": method,
            "params": params,
        }
        self.send(message)

        for line in self.proc.stdout:
            incoming = json.loads(line)

            if incoming.get("method") == "session/update":
                params = incoming.get("params", {})
                update = params.get("update", {})
                if (
                    params.get("sessionId") == self.active_session_id
                    and update.get("sessionUpdate") == "agent_message_chunk"
                ):
                    text = update.get("content", {}).get("text", "")
                    self.chunks.append(text)
                continue

            # 这是只读教学示例,不支持权限、提问或 Plan 等反向请求。
            # 返回明确错误,避免 Agent 和 Client 互相等待。
            if "method" in incoming and "id" in incoming:
                self.send(
                    {
                        "jsonrpc": "2.0",
                        "id": incoming["id"],
                        "error": {
                            "code": -32601,
                            "message": "Client method not implemented in read-only demo",
                        },
                    }
                )
                continue

            if incoming.get("id") != request_id:
                continue
            if "error" in incoming:
                raise RuntimeError(incoming["error"])
            return incoming["result"]

        raise RuntimeError("Cursor Agent 意外退出")

    def start(self):
        self.request(
            "initialize",
            {
                "protocolVersion": 1,
                "clientCapabilities": {
                    "fs": {"readTextFile": False, "writeTextFile": False},
                    "terminal": False,
                },
                "clientInfo": {"name": "python-acp-demo", "version": "0.1.0"},
            },
        )
        self.request("authenticate", {"methodId": "cursor_login"})
        session = self.request(
            "session/new",
            {"cwd": str(self.workdir), "mcpServers": []},
        )
        session_id = session["sessionId"]

        modes = session.get("modes", {})
        available_modes = {mode["id"] for mode in modes.get("availableModes", [])}
        if "ask" not in available_modes:
            raise RuntimeError("当前 Cursor Agent 不支持 ask mode")
        if modes.get("currentModeId") != "ask":
            self.request(
                "session/set_mode",
                {"sessionId": session_id, "modeId": "ask"},
            )

        return session_id

    def ask(self, session_id, prompt):
        self.chunks.clear()
        self.active_session_id = session_id
        self.request(
            "session/prompt",
            {
                "sessionId": session_id,
                "prompt": [{"type": "text", "text": prompt}],
            },
        )
        return "".join(self.chunks)

    def close(self):
        if self.proc.poll() is not None:
            return
        self.proc.stdin.close()
        self.proc.terminate()
        try:
            self.proc.wait(timeout=5)
        except subprocess.TimeoutExpired:
            self.proc.kill()
            self.proc.wait()


if __name__ == "__main__":
    agent = CursorAgent()
    try:
        session_id = agent.start()
        print(agent.ask(session_id, "用一句话解释 ACP 是什么"))
    finally:
        agent.close()

这段代码做完了三件关键的事:

  1. 把 Cursor Agent 作为子进程启动;
  2. 用 JSON-RPC 建立并保留一个 session;
  3. session/update 中拼出 Agent 的流式回复。

Client 没有导入模型 SDK,也没有配置 OPENAI_API_KEY。模型认证和 Agent 工具由 Cursor 托管,但这不代表模型免费或离线运行,只是账号和调用入口不再落到这段业务代码里。

代码里还有两处刻意的收口。cwd 会先解析成绝对路径,这是 ACP 对 session/new 的要求;遇到示例没有实现的 Agent → Client 反向请求时,它会返回 JSON-RPC Method not found,避免双方一直等下去。真正要让 Agent 调工具,不能靠这条兜底。

从「能聊天」到「能干活」还差什么

上面的最小例子只证明链路通了。一个能长期运行的 Agent,还得补几个工程问题。

工作目录决定 Agent 能看到什么

session/newcwd 不只是普通参数。它决定 Agent 从哪里读取项目文件、Rules 或 AGENTS.md,也决定相对路径落在哪里。

示例已经把工作目录解析成绝对路径。正式代码还要把它限制在明确的项目范围内,不要让一个来自聊天软件的 prompt 默认拿到整个用户目录。

Session 要跟业务身份绑定,并发要分层控制

最小示例每次启动都创建一个 session。接到业务系统之后,通常要建立一层映射:

飞书 chat_id  → ACP session_id
网页 user_id  → ACP session_id
工单 ticket_id → ACP session_id

这样同一个群或同一张工单里的多轮消息才能沿用上下文,不同用户也不会串话。Agent 支持 session/load 时,还可以在进程重启后恢复会话。

但 Session 管理不只是映射表。多群同时使用时,要遵守三个并发原则:

  1. Session 内串行 — 同一个群里的上下文是连续的,必须按 FIFO 顺序处理。如果两条消息并发进入 Agent,多轮对话就会乱序。实现上,每个 Session 对应一个 FIFO 队列,新消息入队等待,前一条处理完再取下一条。

  2. Session 间并行 — 不同群之间互不影响,可以并行处理以提高整体吞吐量。每个 Session 独立运行在自己的 asyncio task 中。

  3. 全局限流 — Agent 消耗 CPU、内存和 API 配额。全局 asyncio.Semaphore 限制同时活跃的 Session 数量,避免资源耗尽导致所有会话一起卡死。

# 伪代码示意
class AcpSessionManager:
    def __init__(self, max_concurrent=4):
        self.sessions: dict[str, AcpSession] = {}
        self.semaphore = asyncio.Semaphore(max_concurrent)

    async def handle_message(self, chat_id: str, message: str):
        session = self.sessions.setdefault(chat_id, AcpSession(chat_id))
        await session.enqueue(message)  # FIFO 队列,保证同 Session 串行

这里不能只把示例里的 chunks 改成字典就完事。一个 Agent 子进程只有一条 stdout,多个线程同时读取会拿走彼此的 response,session/update 也可能串到另一个会话。正式 Client 应该只有一个 reader,再用 request ID 把 response 分发给 pending request,用 sessionId 把更新送到各自缓冲区;同一个 session 的 turn 还要串行执行。

权限控制有两层:ACP 协议层和飞书应用层

Agent 要读文件、执行 Shell 或调用 MCP 时,可能向 Client 发起 session/request_permission。Cursor 还可能发出 cursor/ask_questioncursor/create_plan 等扩展请求。桌面编辑器可以弹窗,无人值守的机器人却没人点按钮。

这类消息是 Agent → Client 的 JSON-RPC request,必须由统一 reader 交给对应 handler 并回传 response。教学示例切到了 ask mode,而且对未知反向请求返回错误;工具型 Agent 则要按产品能力实现权限、提问和 Plan handler,不能直接忽略消息,也不能看到权限请求就一律批准。

ACP 协议的权限是第一层:Agent 执行工具前向 Client 申请权限,Client 按策略决定批准或拒绝。对于内部使用、可信环境的飞书助手,我把 auto_approve_permissions 设为 true,让 Agent 自主执行 Shell、读写文件等标准操作。

但实际项目中更常遇到的是第二层——飞书应用层的权限。当 Agent 通过 lark-cli 操作飞书文档、日程或任务时,可能遇到权限不足的情况。这时候的处理流程是:

  1. lark-cli 返回错误 — 响应中包含 permission_violations 字段和一个授权链接;
  2. Agent 转告用户 — 按 AGENTS.md 中写好的规范,Agent 把授权链接发给用户,告诉对方「需要你点击这个链接授权」;
  3. 用户授权后重试 — 用户在浏览器中完成授权,Agent 重新执行操作。

这个「飞书授权关」的设计看起来简单,但它把权限责任清晰地分开了:ACP 层控制 Agent 能不能执行工具,飞书层控制用户有没有给这个应用授权。两层互不干扰,出了问题也容易定位。

最省事做法是自动批准所有权限请求,但这等于让外部消息具备触发本机工具的能力。我的处理思路是:

  • 只允许白名单用户或群聊进入 ACP;
  • 只开放专用工作目录;
  • 查询类动作可以自动批准;
  • 删除、发布、发消息等写操作增加业务确认;
  • 确定性的运维动作直接走固定脚本,不交给模型自由发挥。

权限这一层没想清楚,Agent 越能干,风险越大。

进程和事件流也要当服务维护

教学示例把 stderr 指向了 DEVNULL,避免无人读取时把管道塞满。正式使用则应该持续消费并保存日志;每个请求要有超时;Agent 异常退出后要清理 session 并决定是否重启。收到取消信号时,也应该通过 session/cancel 结束当前 turn。若 Agent 又拉起了其他子进程,还要按操作系统处理整个进程组,而不只是杀掉最外层进程。

关于逐行处理 JSON 消息,可以参考站内的 NDJSON:Agent 开发离不开的流式数据格式

接上业务入口,一个 Agent 就成形了

ACP Client 本身不关心消息来自哪里。最小链路跑通后,外面包一层业务适配器就够了:

业务入口 → 身份与权限校验 → prompt 组装 → ACP session → Agent 回复 → 原路返回

入口可以是飞书消息、HTTP API、桌面窗口、定时任务,也可以是另一个自动化程序。业务层负责「谁在什么场景下提出了什么请求」,Agent 负责「为了完成请求该调用哪些能力」。

完整架构:四层分离

以飞书助手为例,完整架构分四层:

flowchart LR subgraph feishu["飞书"] A["事件推送"] --> B["lark-cli"] end subgraph python["Python 业务层"] C["消息分流"] --> D["上下文传递"] D --> E["权限控制"] E --> F["回复发送"] end subgraph agent["Coding Agent"] G["Agent Loop"] --> H["工具执行"] end subgraph docs["声明式文档"] I["AGENTS.md"] J["SKILL.md"] K["README.md"] end B -->|JSON| C D -->|"ACP session/prompt"| G G -->|"session/update"| E H -.->|"Shell 调用"| B F -->|API| A I -.-> G J -.-> G K -.-> G

飞书生态、Python 业务层、Coding Agent 和声明式文档各司其职。Python 层只做路由和策略,不做推理;Agent 层只做规划和执行,不做消息接入;文档层则让 Agent 在不改代码的情况下获得新能力。

lark-cli 的双重角色

这条链路里同时出现了两个 lark-cli,但职责完全不同:

flowchart LR FS[飞书] -->|事件长连接| LC1[lark-cli event consume] LC1 -->|NDJSON| PY[Python 服务] PY -->|ACP / JSON-RPC| CA[Cursor Agent] CA -->|Shell 命令| LC2[lark-cli] LC2 <-->|飞书 OpenAPI| FS CA -->|回复文本| PY PY -->|lark-cli messages-reply| FS

角色 1:事件入口lark-cli event consume 通过长连接监听飞书事件推送,Python 服务消费 NDJSON 事件流,按 chat_id 分流给对应的 ACP Session。

角色 2:操作工具 — Agent 在执行任务时通过 Shell 调用 lark-cli,例如 lark-cli im +messages-send(发消息)、lark-cli docx +read(读文档)、lark-cli calendar +create-event(建日程)、lark-cli task +create(建任务)。

一个 CLI 统一访问飞书消息、云文档、日历、任务、审批、云盘、通讯录等全部能力。Agent 天然会用 Shell,所以不需要写 Python SDK 代码,也不需要把飞书 API 包成 MCP Server。在 AGENTS.md 里写清楚 lark-cli 的使用规范和安全约束,Agent 就能自动理解并正确调用。

agent acp 是 Python 与 Coding Agent 之间的协议入口;lark-cli 是飞书能力的执行入口。前者解决「怎样驱动 Agent」,后者解决「怎样操作飞书」。

声明式编程的实际效果

我在站内的 利用 Cursor ACP 模式搭建飞书助手机器人,无需任何大模型接口 就是这个架构的完整案例:

  • lark-cli 负责订阅事件、回复消息,也是 Agent 操作飞书资源的 CLI;
  • Python 负责过滤、路由和按 chat_id 复用 session;
  • Cursor ACP 负责让 Python 驱动 Cursor Agent;
  • Cursor Agent 负责规划和执行,需要访问飞书时通过 Shell 调用 lark-cli
  • AGENTS.md 约束 Agent 的角色和操作边界;
  • SKILL.md 声明式定义工作流,新增功能只需加文件;
  • 固定回复与部署脚本绕过模型,走确定性流程。

那篇文章更偏「一个飞书 Agent 怎样落地」,这篇解决的是它前面的一步:为什么可以不从模型 API 开始,以及 ACP Client 最小要写哪些东西。

好处与坏处

把上面这些经验收拢一下,ACP 模式的好处和坏处其实都很明确。

好处:复用 + 声明式 + 解耦

复用成熟 Agent — Cursor Agent 已经做好了 Agent Loop、工具系统、上下文管理和流式输出。不用自己造轮子,直接通过 ACP 驱动。

声明式编程 — 用 AGENTS.md 定义角色,用 SKILL.md 定义工作流,用 agent.yaml 配置行为。几乎不用写 Python,新增功能只需要加一个 Markdown 文件。快速上线,也容易维护。

标准协议解耦 — 业务代码和 Agent 实现分离。换 Agent 只需切换 CLI 命令,业务代码不用改;Agent 升级对 Client 透明;业务层可以独立演进。长期可维护性好。

坏处:黑盒 + 固化 + 依赖

Agent 是黑盒 — 不能控制模型调用逻辑,不能定制工具执行策略,不能干预上下文管理,推理过程不透明。用复用换定制。

流程固化、定制性差 — Agent 封装了既定的执行流程。Agent Loop 和调度策略难以修改,特殊业务流程难以深度适配,不能精细控制模型选择和工具编排。用快速换灵活性。

依赖外部 Agent — 需要 Agent CLI 和登录态,Agent 服务故障直接影响业务,Agent 策略更新不可控,ACP 协议本身还在演进中。用标准换自由度。

这些是架构选型的必然权衡。关键是判断你的项目更需要哪一头。

哪些场景适合这样做

ACP 很适合:

  • 已经在使用某个支持 ACP 的 Agent,希望复用它的模型、工具和配置;
  • 任务开放性强,需要推理和工具链,不是固定步骤;
  • 快速上线优先,声明式编程够用,少写代码;
  • 内部使用、可信环境,中小规模并发,秒级延迟可接受。

下面这些情况,我会更倾向自己掌握模型调用和编排:

  • 需要深度定制 Agent:定制模型调用、推理逻辑、工具策略;
  • 流程高度确定,不需要推理,脚本或工作流引擎更合适;
  • 延迟、token 和模型选择需要精细控制;
  • 高并发、多租户的公开服务,要求毫秒级响应和严格隔离。

三个问题快速判断

如果你不确定自己的项目是否适合 ACP,可以问自己三个问题:

flowchart LR Q1{"核心价值在<br/>业务逻辑?"} -->|是| Q2{"标准能力<br/>够用?"} Q1 -->|否| N["自己写"] Q2 -->|是| Q3{"接受<br/>流程约束?"} Q2 -->|否| N Q3 -->|是| Y["✓ 选 ACP"] Q3 -->|否| N
  1. 核心价值在哪? 如果核心价值在业务逻辑(消息路由、权限控制、数据整合)而不是 Agent 能力本身,选复用。
  2. 标准能力够用吗? 如果成熟 Agent 提供的 Loop、工具和上下文管理已经满足需求,选 ACP;如果需要深度定制每一步,自己写。
  3. 接受流程约束吗? 如果愿意接受既定的 Agent Loop、工具编排和模型选择,选 ACP;如果需要精细控制每个环节,自己写。

飞书助手的决策过程:核心价值在飞书业务逻辑 ✓ → 标准 Agent 能力够用 ✓ → 接受既定流程约束 ✓ → 选 ACP。

ACP 省掉的是重复搭 Agent 运行时的工作,并没有消除工程成本。只是你可以把精力花在业务入口、权限边界和真正有差异的能力上,而不是再次实现工具循环。

展望:DeepSeek Harness 与开放生态

ACP 解决了「复用」的问题,但也引入了「黑盒」的代价。有没有两全其美的办法——既复用成熟 Agent 的能力,又能深度定制每一个环节?

DeepSeek Harnessdsh)提供了一种思路。它是一个基于 Cordis 框架的开源 Agent Harness,核心理念是 Everything is a Plugin:没有不可替换的特权核心,所有能力都从配置和插件树中组合出来。

与传统 Coding Agent 的区别在于:

传统 Coding AgentDeepSeek Harness
模型内置,不可换模型适配器插件,可随时切换
Agent Loop封装在内部插件可改写调度策略
工具系统既定集合注册表可扩展,第三方可并列挂载
Session / 存储内部管理插件可查轨迹、可换持久化
运行时形态固定UI / Runtime 可组合

Cordis 的可逆 effect、依赖生命周期、事件接缝和 HMR 让 Harness 具备了一个有趣的能力:观察 Session 轨迹 → 生成或修改插件 → 热装验证新策略 → 回滚失败或固化成功。这使它成为目前最接近「自演进」形态的 Harness 之一——虽然仍处于 developer preview 阶段。

关键区别是:传统 Harness 允许扩展核心,DSH 连模型、Loop、工具与运行时核心本身都能被替换。Agent 不只「使用能力」,还可以重组承载自己的 Harness。

如果通过 ACP 驱动 DeepSeek Harness,就同时获得了两侧的好处:ACP 提供标准化的 Client 接口和生态复用,DSH 提供完全开放的内部实现和深度定制能力。先用 ACP 快速上线,等遇到黑盒瓶颈时再切到 DSH 做深度调整——业务代码不需要推倒重来。

结语

用 ACP 搭 Agent,最吸引我的地方不是协议本身有多漂亮,而是它把「我想做一个 Agent」缩短成了「我给现成 Agent 接一个入口」。

先用几十行代码把链路跑通,再补 session、权限和业务适配器,一个能干活的 Agent 就有了轮廓。声明式文档让非技术同事也能参与 Agent 的行为设计,标准协议让 Agent 替换变成了换一行配置的事情。至于那些确定性很强的事情,还是老老实实写脚本——能一行 Bash 说清楚的,没必要让模型猜。

如果未来需要更深的定制能力,DeepSeek Harness 这样的开放生态已经准备好了。ACP 是入口,开放 Harness 是纵深。两层搭配,才是一个 Agent 系统从「能用」走到「好用」的完整路径。

参考资料

ACP 官方规范

SDK 与集成

DeepSeek Harness

底层标准

站内相关

版权声明: 本文首发于 指尖魔法屋-不写 Agent Loop:用 ACP 快速搭一个能干活的 Agenthttps://blog.thinkmoon.cn/post/1035-build-agent-with-acp/) 转载或引用必须申明原指尖魔法屋来源及源地址!