☰
Emacs AI工作台:基于ACP协议与Lisp的agent-shell多智能体协作实践
2026/10/28 5:12:43 网站建设 项目流程

1. 为什么要在 Emacs 里折腾一个 AI 工作台

用 Emacs 的人大概都有一种执念:希望所有事情都能在同一个环境里完成,不切窗口、不换工具、不打断心流。写代码、记笔记、跑终端、管日程,这些早就被各种 mode 安排得明明白白。可到了 AI 这一块,情况就有点尴尬了——要么在浏览器和编辑器之间反复横跳,要么装个插件但功能残缺,要么干脆放弃 Emacs 去用别的编辑器。

agent-shell这个项目要解决的就是这个问题。它的核心思路很直接:在 Emacs 里开一个 shell 缓冲区,但这个 shell 不是用来跑 bash 的,而是用来跟 AI agent 对话的。你可以把它理解成一个“AI 工作台”,所有跟模型交互的操作都收敛到一个 buffer 里,输入输出都是纯文本,跟 Emacs 原有的操作逻辑无缝衔接。

这个项目适合谁?三类人值得关注。第一类是重度 Emacs 用户,日常写代码、写文档都在 Emacs 里完成,希望 AI 能力直接嵌入现有工作流。第二类是对 AI agent 协作模式感兴趣的开发者,想搞清楚 agent 之间怎么通信、怎么编排。第三类是喜欢折腾工具链的人,享受把不同组件拼装成一个顺手系统的过程。

关键词里提到的 ACP 是理解这个项目的关键。ACP 全称 Agent Client Protocol,是一套定义 agent 与客户端之间通信方式的协议。你可以把它类比成 LSP——LSP 让编辑器能跟语言服务器对话,ACP 让编辑器能跟 AI agent 对话。有了这层协议,agent 的实现和客户端的实现就解耦了,Emacs 这边只需要实现 ACP 的客户端部分,后端接什么模型、用什么框架,都可以灵活替换。

Lisp 在这里的角色不用多说,Emacs 的扩展语言就是 Emacs Lisp,整个 agent-shell 的交互逻辑、buffer 管理、键绑定都是用 Elisp 写的。如果你之前没写过 Elisp,这个项目其实是个不错的练手材料,因为它的功能边界清晰,代码量可控,读起来不会太吃力。

2. 整体架构与设计思路拆解

2.1 为什么选择 shell buffer 作为交互载体

Emacs 里做 AI 交互,常见方案有三种:弹窗式(比如某些补全插件)、侧边栏式(类似 Copilot 的 panel)、以及 buffer 式。agent-shell 选了第三种,这个选择背后有很实际的考量。

弹窗式的问题是打断感太强,每次触发都要弹出来再收回去,适合短交互但不适合长对话。侧边栏式看起来优雅,但 Emacs 的窗口管理本身就够复杂了,再加一个常驻侧边栏,小屏幕上基本没法用。buffer 式的好处是它天然融入了 Emacs 的 buffer 管理体系——你可以用C-x b切换过去,可以用split-window分屏对照,可以用save-buffer保存对话记录,所有你熟悉的操作都能直接用。

更关键的是,shell buffer 天然支持流式输出。AI 生成内容是逐 token 返回的,如果用一个普通 buffer 来接收,需要自己处理异步插入和光标位置。而 shell buffer 本身就是为流式输出设计的,comint-mode提供了现成的机制来处理持续输出的场景。这个选择省掉了大量底层工作。

2.2 ACP 协议的分层设计

ACP 的设计思路跟 LSP 很像,分成传输层、消息层、语义层三层。

传输层负责字节流的收发,通常用 stdio 或者 socket。消息层定义消息的格式,一般是 JSON-RPC 2.0,包含 method、params、id 这些字段。语义层定义具体的方法名和参数结构,比如agent/chat、agent/cancel、agent/tool_call这些。

这种分层的好处是每一层可以独立替换。传输层从 stdio 换成 websocket,上层代码不用动。消息层从 JSON-RPC 换成别的格式,只要语义层的方法定义不变,客户端逻辑也不用大改。对于 Emacs 这种 Lisp 环境来说,JSON 的解析和生成有现成的库(json-parse-string、json-serialize),所以消息层的实现成本很低。

实际用的时候,agent-shell 会启动一个子进程作为 agent 的运行时,然后通过 stdio 跟它通信。这个子进程可以是 Python 写的,可以是 Node 写的,甚至可以是另一个 Lisp 进程。只要它实现了 ACP 定义的方法,就能被 agent-shell 驱动。

2.3 多 agent 协作的编排逻辑

热搜词里提到了“多 AI 协作”,这在 agent-shell 的架构里是有体现的。因为 ACP 把 agent 抽象成了一个标准接口,所以你可以同时启动多个 agent 进程,每个负责不同的任务。比如一个 agent 专门做代码生成,一个 agent 专门做代码审查,一个 agent 专门做文档撰写。

编排的逻辑可以放在 Emacs 这边,用 Elisp 写一个简单的调度器。收到用户输入后,根据输入的内容类型决定发给哪个 agent,或者同时发给多个 agent 然后汇总结果。这种模式的好处是每个 agent 可以独立配置模型和提示词,不用在一个 agent 里塞太多职责。

当然,多 agent 协作也带来了新的问题,比如上下文怎么共享、结果怎么合并、冲突怎么处理。这些在后面的实操部分会具体讲。

3. 核心细节解析与实操要点

3.1 环境准备与依赖安装

先把基础环境搭起来。你需要一个 Emacs 27 以上的版本,因为json-parse-string这些函数在 27 之后才比较稳定。检查版本用emacs --version,如果低于 27,建议先升级。

然后是 agent 运行时的准备。agent-shell 本身只是一个客户端,它需要一个实现了 ACP 的 agent 进程才能工作。这个 agent 进程通常是一个独立的项目,用 Python 或 Node 写的居多。你需要先把它 clone 下来,装好依赖,确保能独立运行。

以 Python 实现的 agent 为例,典型的依赖包括asyncio(异步 IO)、jsonrpc相关的库、以及具体模型的 SDK。安装的时候建议用虚拟环境,避免污染系统 Python。命令大概是这样的:

python3 -m venv ~/.local/share/agent-shell/venv source ~/.local/share/agent-shell/venv/bin/activate pip install -r requirements.txt

装完之后先手动跑一下 agent 进程,确认它能正常启动并响应请求。这一步很重要,因为如果 agent 本身有问题,在 Emacs 里调试会麻烦很多。

3.2 Emacs 侧的配置要点

agent-shell 的 Emacs 配置主要涉及三块:加载路径、启动参数、键绑定。

加载路径方面,如果你是用straight.el或者quelpa管理包,直接写对应的声明就行。如果是手动管理,把项目目录加到load-path里,然后(require 'agent-shell)。

启动参数需要指定 agent 进程的可执行文件路径和启动参数。这部分通常是一个 plist 或者 alist,包含:command、:args、:env这些字段。:env里可以设置模型 API 的地址和密钥,注意不要把密钥硬编码在配置文件里,用auth-source或者环境变量来管理。

键绑定建议放在agent-shell-mode-map里,常用的操作包括发送输入、取消当前请求、清空对话、切换 agent。我个人的习惯是把发送绑定到C-c C-c,取消绑定到C-c C-k,这样跟comint-mode的默认习惯保持一致。

3.3 对话上下文的维护策略

AI 对话跟普通 shell 命令最大的区别是上下文。普通 shell 命令是无状态的,每条命令独立执行。AI 对话是有状态的,后面的回复依赖前面的内容。agent-shell 需要维护一个对话历史,每次发送请求时把历史一起带上。

历史维护有两个策略:全量携带和滑动窗口。全量携带是把所有历史都发给模型,优点是上下文完整,缺点是 token 消耗大,而且超过模型上下文长度后会报错。滑动窗口是只保留最近 N 轮对话,优点是 token 可控,缺点是可能丢失早期的重要信息。

实际用下来,比较稳妥的做法是混合策略:保留一个系统提示词(始终携带),加上最近 N 轮对话(N 根据模型上下文长度动态调整),再加上一些关键信息的摘要(比如用户之前提到的偏好、约束条件)。摘要可以用另一个轻量模型来生成,成本不高但效果明显。

3.4 工具调用的实现细节

agent-shell 不只是聊天,它还支持工具调用。所谓工具调用,就是 agent 在执行任务的过程中,可以请求客户端执行某些操作,比如读文件、写文件、跑命令。这个能力让 agent 从“只会说话”变成“能干活”。

工具调用的流程是这样的:agent 在回复中返回一个工具调用请求,包含工具名和参数。客户端收到后执行对应的操作,把结果返回给 agent。agent 拿到结果后继续生成后续内容。

在 Emacs 这边,工具的实现就是一些 Elisp 函数。比如read-file工具对应(with-temp-buffer (insert-file-contents path) (buffer-string)),write-file工具对应(with-temp-file path (insert content))。需要注意的是,工具执行要有权限控制,不能让 agent 随意读写任意文件。建议维护一个白名单,只允许访问特定目录。

注意:工具调用的参数校验很重要。agent 生成的参数可能不符合预期,比如路径不存在、内容格式错误。每个工具函数都要做防御性编程,出错时返回明确的错误信息,而不是直接抛异常。

4. 实操过程与核心环节实现

4.1 从零启动一个 agent 进程

假设你已经准备好了 agent 的代码,现在要把它跑起来。第一步是确认 agent 的入口文件,通常是一个main.py或者server.js。用--help参数看看它支持哪些启动选项,重点关注端口、模型、日志这几个。

启动命令大概是这样的:

python agent_main.py --transport stdio --model gpt-4 --log-level info

--transport stdio表示用标准输入输出通信,这是 agent-shell 默认的方式。启动后 agent 会等待客户端发送初始化请求,收到后返回自己的能力列表。你可以手动发一个初始化请求测试一下:

{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"client": "test"}}

如果 agent 正常响应,说明进程本身没问题。接下来就是在 Emacs 里配置 agent-shell 去启动这个进程。

4.2 Emacs 侧的完整配置示例

下面是一段可以直接参考的配置,放在init.el或者单独的agent-shell-config.el里:

(use-package agent-shell :load-path "~/projects/agent-shell" :config (setq agent-shell-agents '(("default" :command "python" :args ("~/projects/agent-runtime/agent_main.py" "--transport" "stdio" "--model" "gpt-4") :env (("AGENT_API_KEY" . (lambda () (auth-source-pick-first-password :host "agent-api")))) :work-dir "~/projects/workspace"))) (setq agent-shell-default-agent "default") (setq agent-shell-history-size 20) (setq agent-shell-tool-whitelist '("read-file" "write-file" "list-dir" "run-command")) :bind (:map agent-shell-mode-map ("C-c C-c" . agent-shell-send-input) ("C-c C-k" . agent-shell-cancel) ("C-c C-l" . agent-shell-clear)))

这段配置里几个关键点值得说明。:env里用了一个 lambda 来动态获取密钥,这样密钥不会出现在配置文件里。:work-dir限制了 agent 的工作目录,工具调用只能在这个目录下操作。:tool-whitelist限制了可用的工具,不在白名单里的工具调用会被拒绝。

4.3 一次完整的对话流程拆解

配置好之后,M-x agent-shell启动一个新的 shell buffer。buffer 名字默认是*agent-shell*,你可以改成更有意义的名字。

启动后先发一个简单的问候,比如“你好,帮我看看当前目录下有哪些文件”。agent 收到后会先返回一个工具调用请求,要求执行list-dir。客户端执行后把结果返回,agent 再根据结果生成自然语言回复。

这个过程在 buffer 里看起来是这样的:

> 你好,帮我看看当前目录下有哪些文件 [agent] 正在调用工具: list-dir [agent] 工具返回: ["main.py", "utils.py", "README.md"] 当前目录下有 3 个文件:main.py、utils.py 和 README.md。 其中 main.py 是主程序入口,utils.py 应该是工具函数, README.md 是说明文档。需要我帮你看看某个文件的内容吗?

整个流程是异步的,发送输入后不用等,可以继续在 Emacs 里做别的事。agent 的回复会逐段插入到 buffer 里,插入位置自动跟随光标。如果回复太长,可以用C-c C-l清空重新开始。

4.4 多 agent 协作的配置方式

要启用多 agent 协作,需要在配置里定义多个 agent,然后写一个调度函数。比如:

(setq agent-shell-agents '(("coder" :command "python" :args ("coder_agent.py")) ("reviewer" :command "python" :args ("reviewer_agent.py")) ("writer" :command "python" :args ("writer_agent.py")))) (defun my/agent-shell-dispatch (input) "根据输入内容决定发给哪个 agent" (cond ((string-match-p "\\(写\\|生成\\|实现\\)" input) (agent-shell-send-to "coder" input)) ((string-match-p "\\(审查\\|检查\\|review\\)" input) (agent-shell-send-to "reviewer" input)) (t (agent-shell-send-to "writer" input))))

这个调度逻辑很粗糙,实际用的时候可以做得更精细,比如用一个小模型来做意图分类,或者让用户手动指定 agent。多 agent 协作的难点不在调度,而在上下文共享。如果 coder 生成了代码,reviewer 要审查,那 reviewer 需要知道 coder 生成了什么。这个可以通过共享一个全局的上下文变量来实现,或者把 coder 的输出作为 reviewer 的输入。

5. 常见问题与排查技巧实录

5.1 agent 进程启动失败

最常见的问题是 agent 进程启动不起来。表现是M-x agent-shell后 buffer 里没有任何输出,或者直接报错。

排查思路分三步。第一步,在终端里手动跑 agent 的启动命令,看有没有报错。如果终端里也跑不起来,那就是 agent 本身的问题,跟 Emacs 无关。第二步,检查 Emacs 里的:command和:args是否正确,特别是路径有没有展开(~在 Elisp 字符串里不会自动展开,需要用expand-file-name)。第三步,检查环境变量,agent 需要的 API 密钥有没有正确传递。

实操心得:在:args里加一个--log-file参数,让 agent 把日志写到文件里。这样即使 Emacs 这边看不到输出,也能从日志文件里找到线索。

5.2 中文乱码问题

如果 agent 返回的内容包含中文,可能会显示成乱码。这是因为编码不一致导致的。agent 进程默认可能用 UTF-8,但 Emacs 的 shell buffer 可能用了别的编码。

解决方法是在启动 agent 时显式指定编码,同时在 Emacs 这边设置comint-process-echoes和locale-coding-system。具体来说:

(setq locale-coding-system 'utf-8) (setq-default buffer-file-coding-system 'utf-8-unix)

如果还是乱码,检查 agent 进程的PYTHONIOENCODING环境变量,确保它是utf-8。

5.3 流式输出卡顿

流式输出卡顿通常是因为 Emacs 的 redisplay 太频繁。每收到一个 token 就刷新一次屏幕,token 多了之后就会卡。

解决办法是批量刷新。不要每收到一个字符就插入,而是攒一小段再插入。comint-mode本身有节流机制,但默认值可能不适合 AI 场景。可以调整comint-output-filter-functions,加一个自定义的节流函数。

另一个原因是 buffer 太大。对话历史长了之后,buffer 里的内容会很多,每次插入都要重新计算光标位置。定期清空 buffer 或者把历史存到单独的文件里,能明显改善性能。

5.4 工具调用权限被拒绝

如果 agent 请求调用某个工具但被拒绝了,先检查agent-shell-tool-whitelist里有没有这个工具。白名单是精确匹配的,工具名大小写不一致也会导致拒绝。

如果白名单里有但还是被拒绝,检查工作目录。工具调用的路径参数如果是相对路径,会基于:work-dir解析。如果解析后的路径不在允许范围内,也会被拒绝。建议在工具函数里加日志,把解析后的绝对路径打出来,方便排查。

5.5 常见问题速查表

问题现象可能原因排查方法
buffer 无输出agent 进程未启动终端手动运行 agent 命令
中文乱码编码不一致检查 locale 和 PYTHONIOENCODING
流式输出卡顿redisplay 太频繁调整节流参数,定期清空 buffer
工具调用被拒白名单或路径问题检查白名单和工作目录配置
上下文丢失历史窗口太小增大 history-size 或加摘要
响应超时模型服务不可达检查网络和 API 地址配置

6. 进阶玩法与扩展方向

6.1 把 agent-shell 接入现有工作流

agent-shell 跑起来之后,可以跟 Emacs 的其他功能结合。比如用org-mode管理任务,写一个函数把当前 task 的内容发给 agent,让 agent 生成实现方案。或者用dired浏览文件时,选中一个文件直接发给 agent 做代码审查。

这些集成的实现方式都是写一个 Elisp 函数,从当前上下文提取信息,调用agent-shell-send-to发送。关键是要定义好上下文的格式,让 agent 能理解你发过去的是什么。

6.2 自定义工具扩展 agent 能力

内置的工具通常只有文件读写和命令执行,但你可以根据自己的需求加新工具。比如加一个search-web工具,让 agent 能搜索网页;加一个run-test工具,让 agent 能跑测试并看结果。

自定义工具的步骤是:在 agent 侧注册工具定义(名字、参数、描述),在 Emacs 侧实现对应的 Elisp 函数,然后把函数名加到agent-shell-tool-handlers里。工具的描述很重要,agent 是根据描述来决定什么时候调用这个工具的。描述要写清楚工具的功能、参数含义、返回值格式。

6.3 对话记录的持久化与检索

默认情况下,对话记录只存在 buffer 里,关掉就没了。如果想保留,可以写一个 hook,在 buffer 关闭时把内容追加到文件里。文件格式建议用org-mode,这样可以用org-agenda做检索,也可以用org-babel把代码块提取出来直接运行。

检索方面,如果记录多了,可以用ripgrep做全文搜索,或者用sqlite建一个简单的索引。我个人的做法是按日期分文件,每天一个 org 文件,文件里按对话轮次分节。找东西的时候用consult-ripgrep搜一下,很快。

6.4 性能优化的几个方向

如果觉得 agent-shell 用起来不够流畅,可以从几个方向优化。

第一是减少不必要的上下文传输。每次发送请求时,只带必要的上下文,不要把整个 buffer 都发过去。第二是缓存模型响应。相同的输入如果之前问过,可以直接从缓存里取,不用再调模型。第三是并行化。多个 agent 的请求可以并行发送,不用串行等待。第四是懒加载。agent 进程不用一开始就启动,等第一次发送请求时再启动,减少 Emacs 启动时间。

这些优化不是必须的,但如果你的使用频率很高,做了之后体验会好很多。我自己的配置里加了响应缓存,重复问题的响应时间从几秒降到了毫秒级,效果很明显。

6.5 安全使用的几点提醒

最后说几个安全方面的注意事项。第一,API 密钥不要硬编码在配置文件里,用auth-source或者环境变量。第二,工具调用的白名单要严格,不要图方便开放所有工具。第三,工作目录要限制在项目目录内,不要让 agent 有机会访问系统文件。第四,对话记录里可能包含敏感信息,保存到文件时注意权限设置。第五,如果 agent 要执行命令,最好加一个确认步骤,让用户确认后再执行。

这些措施看起来麻烦,但都是必要的。AI agent 的能力越强,潜在的风险就越大。把安全边界划清楚,用起来才安心。

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

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

立即咨询