1. 为什么我把 AI agent 搬进 Emacs:工作台选型的底层逻辑
先说说我最近的一段真实体验。过去半年我一直在折腾各类 AI 编程工具,从 IDE 插件到独立的 agent 终端都试过一轮。最终留在日常主力环境里的,不是那些开箱即用的商业产品,而是 Emacs 里一个叫 agent-shell 的自建工作台。听起来有点复古?但当你真正把一个 AI agent 放进 Emacs 这个四十多岁的编辑器里,会发现它做的事情远不止"帮你写代码"这么简单——它能让整个工作台具备自我进化的能力。
1.1 为什么是 Emacs 而不是 IDE 插件
市面上主流的 AI 编程插件,本质上都是围绕"补全"和"对话"设计的。它们运行在 IDE 提供的封闭扩展点里,你只能在编辑器划出的对话框里提问、接受代码建议,偶尔让它跑一下测试。问题在于:这种交互模型把 AI 当成一个"顾问",而不是一个"执行者"。
Emacs 的底层逻辑完全不同。它的核心是一个 Elisp 解释器,所有编辑功能、窗口管理、模式切换都是 Elisp 代码。这意味着你可以在运行时加载一段新代码,让编辑器立刻获得新能力——不需要重新编译,不需要重启进程。如果把 IDE 比作一台焊死电路板的设备,Emacs 就是一块可以随时插拔模块的试验台。
agent-shell 正是踩中了这个特性。它本质上是一个 Emacs 小工具(minor mode),把 AI 模型的对话能力、Shell 命令执行能力、Elisp 代码求值能力三者串成一条流水线。AI 理解你的意图后生成动作,Emacs 替你执行动作,再把结果反馈给 AI 继续迭代。这个闭环跑通之后,AI 就不再是"回答问题"的聊天框,而是一个真的能干活、能试错、能修改自身工具的工程师。
1.2 所谓"进化"的技术前提
标题里"自我进化"这个词,可能有人觉得夸张。但拆开看,它依赖三个技术前提,恰好 Emacs 全部具备:
第一,可热更新的运行环境。Elisp 支持eval-last-sexp、load-file这类运行时求值手段。AI 生成一段 Elisp 代码,Emacs 可以直接加载运行,这个过程不需要离开当前会话。我实测过,从模型生成代码到函数生效,延迟基本在秒级。
第二,统一的工具入口。Shell 是操作系统的万能接口,Emacs 通过shell-command、make-process等函数可以无缝调用外部程序。AI agent 要读文件、跑脚本、查日志、执行 git 操作,全部可以归结为"生成一段 shell 命令并执行"。Emacs 对这些调用有完整的输出捕获机制,AI 能看到结果,进而调整下一步动作。
第三,可持久化的配置体系。Emacs 的配置就是一个含多个 Elisp 文件的目录,agent 每次学到的经验、沉淀的工具函数,都可以写入配置目录中的文件。下次启动 Emacs 时,这些能力自动加载,相当于 AI 的记忆被固化进了工具本身。
这三个条件凑齐,就形成了一条进化链:AI 执行任务 -> 发现重复操作 -> 将操作固化成 Elisp 工具 -> 工具被持久化加载 -> AI 下次用更短路径完成任务。这篇文章后面会具体讲这条链路怎么建立。
2. agent-shell 的工作机制:从"对话"到"操作"的桥梁
理解了选型逻辑,接下来拆解 agent-shell 的核心机制。很多人第一次用这类工具时最大的困惑是:AI 模型只是一个文本生成接口,它怎么知道该执行什么命令?又是怎么把命令跑通的?
2.1 一次完整任务的三段式流水线
一次对话驱动的 shell 操作,在 agent-shell 里被拆成三段。
第一段是意图解析。模型收到用户输入(例如"看看当前 git 仓库里有哪些未提交的改动,顺便列出最近改动的文件")后,模型依据预设的工具描述,把它拆解为具体的动作序列。这一步在模型内部完成,agent-shell 只需要按照 OpenAI 兼容格式准备好 system prompt,告诉模型当前工作目录、可用工具和输出要求。
第二段是动作生成与执行。模型返回一个结构化的 JSON,包含工具类型(比如shell、read_file)和参数(命令字符串、文件路径等)。agent-shell 解析 JSON 后,调用 Emacs 的进程管理函数执行对应操作。这里有一个关键设计:工具名称集合在 prompt 里以严格的 schema 声明,模型只能从中选择。只要 prompt 写得清楚,模型极少胡来。
第三段是结果回填与继续。shell 命令的输出被截断到合理长度(默认 4000 字符,可配置),连同退出码一起拼进对话上下文,再发给模型。模型看到结果后决定是继续下一步、修正刚才的命令还是直接结束任务。这个循环可以持续多轮,直到模型认为任务完成,返回一个 FINAL 标志,agent-shell 才退出执行状态。
2.2 进程调用与输出捕获的 Elisp 实现
真正动手做这个工具时,最费精力的部分不是 AI 接口对接,而是进程管理。Emacs 执行 shell 命令有几种方式,每种都有取舍:
| 方式 | 特点 | 适用场景 |
|---|---|---|
shell-command | 同步执行,阻塞编辑器,输出直接插入 buffer | 快速跑一条命令,不关心并发 |
make-process | 异步执行,不阻塞编辑,通过 sentinel 回调捕获结束事件 | 常规 agent 命令执行 |
start-process | 底层接口,需要手动管理 buffer 和过滤函数 | 需要精细控制输出的场景 |
async-shell-command | 异步但输出进临时 buffer,无法直接拿到字符串 | 看日志、启动服务 |
我最终用的是make-process+ 自定义过滤函数。核心逻辑是:先创建一个临时 buffer 作为输出容器,make-process的:filter参数传入一个函数,每收到一段新输出就追加到这个 buffer 里;:sentinel参数在进程退出时被触发,此时读取整个 buffer 内容、检查退出码、把结果回传给模型请求函数。
这个方案的优点是异步非阻塞,命令执行期间用户还可以继续在 Emacs 里做别的事,这在跑长时间构建任务时特别重要。缺点是代码复杂度高一些,要处理好输出被截断、进程被杀掉、连续执行多条命令时的状态切换。下面是一段简化后的核心代码:
(defun agent-shell--run-command (cmd &optional timeout) "异步执行 CMD,返回 (output . exit-status) 风格的 alist。" (let* ((buf (generate-new-buffer "*agent-shell-output*")) (output "") (proc (make-process :name "agent-shell-task" :buffer buf :command (list "bash" "-c" cmd) :connection-type 'pipe :sentinel (lambda (process event) (unless (process-live-p process) (let ((full-output (with-current-buffer buf (buffer-string)))) (setq output full-output) (kill-buffer buf) (agent-shell--on-finish output (process-exit-status process))))) :filter (lambda (proc str) (when (buffer-live-p (process-buffer proc)) (with-current-buffer (process-buffer proc) (goto-char (point-max)) (insert str))))))) proc))这只是一个最朴素的版本。实际使用中我加了超时处理(定时跑kill-buffer和kill-process)、输出压缩(只保留头尾各 1000 字符)以及敏感命令确认钩子。后面讲护栏配置时再展开。
2.3 为什么"把 JSON 作为模型和执行的中间格式"是必要的
这里值得多说两句设计决策。为什么不让模型直接输出 shell 命令字符串,而要输出 JSON?
早期版本我试过让模型直接返回命令,我拿到字符串就执行。看起来简单,但很快发现两个问题。第一,模型经常在一个回复里塞好几条命令,夹着解释文字,解析起来很不可靠;第二,一旦命令执行失败,模型看不到"失败状态",因为它不知道退出码,只能靠输出文本猜测,这会导致它重复尝试同一条错命令。
改成结构化 JSON 之后,一切清晰了。模型输出的工具调用层可以用函数调用模式来约束,每一轮只干一件事,成功失败都有显式状态回传。JSON 里还带thought字段,模型可以写下它的判断理由,这既方便调试,也让 agent 的行为可解释。代价是每轮多了一些 token 消耗,以及 prompt 里得塞一份完整的工具 schema 定义——但这部分开销,换来的是系统稳定性的显著提升。我自己的项目从改成 JSON 协议后,多轮任务的失败率至少下降了一半。
3. 让 agent 会干活:工具集设计与任务分发
有了执行通道,接下来要解决的是"agent 到底能调用哪些工具"。只留一个 shell 窗口远远不够,现实中的任务往往需要读文件、查找代码、运行测试、查询 git 状态…… 工具集的丰富程度直接决定了 agent 的上限。
3.1 最小可用工具集:我推荐从这六个开始
如果你也要做一个类似的工作台,我建议从以下六个工具起步,不多不少刚刚好:
| 工具名 | 功能说明 | 底层命令 |
|---|---|---|
shell | 执行任意 shell 命令 | bash |
read_file | 读取文件内容并可选定位到指定行范围 | cat / sed |
write_file | 写文件、创建新文件 | cat + heredoc |
glob | 查询目录结构、文件名匹配 | find / fd |
grep | 在项目中搜索文本 | rg / grep |
git_status | 查看当前仓库状态和 diff | git status / git diff |
设计这个工具集时遵循一个原则:让 agent 具备"查看现状 -> 修改文件 -> 验证结果"闭环所需的最小能力。多一个用不上的工具,prompt 就更长,模型误选工具的概率也更高。
read_file和grep这两个工具要特别注意"结果截断"的配置。有一次 agent 在处理一个大型项目时,glob扫出了一个包含几万个文件的node_modules目录,直接把上下文撑爆了。后来我在每个文件相关工具里都加了路径排除列表,默认忽略.git、node_modules、dist这类目录,同时在 prompt 里明确告诉模型"使用前尽量先 glob 确认目标存在,不要盲目读取大文件"。
3.2 工具选择不靠猜:prompt 里的 schema 声明方法
当前主流模型都支持函数调用(function calling),agent-shell 利用的就是这个能力。关键实现是把工具定义写成 JSON schema,塞进 system prompt,然后要求模型在需要调用工具时返回一个特殊的 JSON 结构。
举例,定义shell工具的 schema 片段大致这样:
{ "type": "function", "function": { "name": "shell", "description": "在项目目录下执行一条 shell 命令,获取输出。", "parameters": { "type": "object", "properties": { "command": { "type": "string", "description": "要执行的命令,必须是开发者预设白名单允许的。" }, "timeout_seconds": { "type": "integer", "description": "命令超时时间,默认 30,最长 120。" } }, "required": ["command"] } } }schema 里最好写明每个字段的作用和约束,这能显著减少模型的误用。比如我在command描述里写"必须是白名单内命令或明显的文件读取/修改操作",模型的违规调用一下就少了很多。
实际对话时的模型返回格式是这样:
{ "tool_calls": [ { "id": "call_123", "function": { "name": "shell", "arguments": "{\"command\": \"git status --short\"}" } } ] }agent-shell 拿到这个结构后,根据function.name找到对应的 Elisp 处理函数,然后把arguments解析成 plist,分派执行。如果模型在回复里没有tool_calls字段,说明它认为任务已经完成,agent-shell 就把文本内容作为最终回答展示给用户。
3.3 多工具协作:让 agent 按项目地图工作
单一工具调用看起来平平无奇,但多个工具串起来就完全不一样了。我日常用得最多的一个 pattern 是这样:
用户输入"给这个项目的 README 补一段架构说明"。agent 会依次调用glob查看目录结构,read_file读取几个核心入口文件,可能还会grep搜一下函数定义,最后write_file生成新的 README 段落。全程不需要用户介入,每步都会输出执行结果。
这里有个体验优化技巧:agent-shell 在每轮工具调用后,会短暂显示"正在执行 xxx 工具"的状态行,用户能看到它在干什么,如果 agent 跑偏了可以立即打断。这个反馈机制在早期试错阶段特别重要,能帮你快速发现工具定义和 prompt 里的问题。
4. 真正的自我进化循环:让 agent 修改自己的工作台
现在来到本文的重头戏——标题里所说的"自我进化"。如果你的 agent 只是会跑命令、写文件,那它还只是一个好用的工具。真正让它"进化",需要建立一个循环:agent 在干活的过程中,发现未来还会用到类似操作,就把操作固化成新的工具函数,写回 Emacs 配置。下次再遇到同类需求,一行命令搞定,不需要重新对话。
4.1 把重复劳动 "固化" 成工具函数
我在 agent-shell 里设计了一个agent-shell:learn机制,触发条件很简单:当 agent 连续两次以上使用同一段多步命令完成类似任务时,agent 会在回复中主动建议"这个流程要不要我固化成一个新工具?"。
比如,我的一个项目里经常需要"找出最近的失败日志并提取异常堆栈"。第一次 agent 是这样干的:先grep -rn "ERROR" logs/,再过滤时间戳,再tail -n 50,然后人工整理成一段总结。这个流程跑了三遍之后,agent 在某一轮对话里提出了固化建议。
我同意后,agent-shell 会调用一个agent-shell:learn-from-history函数:把最近几轮对话里提取的命令行序列和工具调用参数折叠成一个模板函数,自动生成 Elisp 代码,追加到我的~/.emacs.d/lisp/agent-tools.el文件里,然后load-file热加载。
生成的函数类似这样:
(defun agent-tools:last-error-log () "提取最近一次错误日志的堆栈信息。" (interactive) (let ((cmd "grep -rn 'ERROR' logs/ | tail -n 50")) (shell-command-to-string cmd)))从这以后,用户再输入"看看刚才的报错",agent 会直接引用这个工具函数,而不是从零开始跑命令。能力就这样沉淀下来了。
4.2 配置热加载:Elisp 让它真正"活"起来
这个过程里,最核心的技术支撑是热加载。Elisp 中函数可以被重新定义,load-file会在运行时重新评估整个文件,把新函数装进当前会话。这意味着 agent 生成的函数体立即生效,不需要重启。
(load-file "~/.emacs.d/lisp/agent-tools.el")运行这行代码后,agent-tools:last-error-log函数对当前会话可用,而且因为 Emacs 的配置本质是"加载的代码总和",下次启动时init.el里(require 'agent-tools)也会自动把这些函数加载进来。这比传统程序"改完代码要重新构建部署"的循环短得多——AI 的产出物,当场就能成为工具的一部分。
需要注意一个坑:自动生成的函数要特别小心命名空间冲突。Elisp 没有真正的局部函数隔离,导出的符号会全局可见。如果 agent 生成一个名为my-helper的函数,很可能和别的包冲突。我在learn机制里强制所有自动生成的函数带上固定的agent-tools:前缀,并且每次生成前扫描一次obarray(Emacs 内部符号表)查重,从源头避免冲突。
4.3 对话历史与"经验"的分层沉淀
自我进化不只是生成工具函数,还包括经验知识的沉淀。我做了另一个设计:每次 agent 完成一个复杂任务后,把任务描述、步骤序列、最终结果总结为一个"配方卡片",追加到一个 markdown 文件里。
这个文件同时满足两个需求:一是给人看——我偶尔翻一下,能回忆起这个工作台最近帮我完成了哪些任务;二是给模型看——每个配方卡片的开头注明了任务类型和关键词,下一次 agent 收到类似任务时,会先搜索这个文件,把相关配方当作 few-shot 示例塞进 prompt。
这个做法的效果非常显著。我做过对比:在沉淀了二十多个配方卡片后,agent 处理相似任务时,工具调用轮次平均减少了 40% 左右。因为模型不需要每一次都从头推理怎么做,直接在历史经验上做小修小改就行了。这就是"进化"最直观的体现——不是模型本身变聪明了,是它可调用的上下文、可复用的工具变多了,解决问题的能力自然就上去了。
5. 筑好护栏:权限、职责与失败的边界
讲完了进化的路径,必然要讲约束。一个能自己执行命令、修改文件甚至写代码加载进编辑器的 agent,听起来很强大,如果不加控制,它也能把环境搞得一团糟。我在实际使用中踩过的坑,基本都集中在权限和资源控制上——AI 没有常识,你必须在 prompt 之外用硬代码给它画好边界。
5.1 命令白名单与默认拒绝策略
最简单也最有效的护栏,是命令白名单。agent-shell 有一个agent-shell:allowed-commands全局变量,是一个命令前缀列表。只有当命令以某个前缀开头时,才允许执行;否则直接拒绝并告知模型。
以下是我的基础配置:
(setq agent-shell:allowed-commands '("git" "cd" "ls" "cat" "rg" "grep" "find" "sed" "awk" "curl" "python" "python3" "node" "npm run" "npx" "make" "cmake" "bash"))策略很明确:默认拒绝、显式放行。模型的工具描述里也写明了这个限制,所以它生成命令时会主动收敛到白名单内。遇到像rm -rf这类危险操作,我加了关键词黑名单,即使命令以白名单开头也会被拦截。比如git clean -fdx这种命令,因为包含clean关键词,会被单独弹窗要求人工确认。
提示:这里的关键不是阻止所有危险命令,而是确保危险操作必须经过人工确认。AI 犯错的概率不高,但一旦发生,成本可能极高。
5.2 工作目录隔离与路径约束
除了命令维度,路径维度同样重要。我最开始没有做隔离,agent 可以在家目录甚至根目录随意读写文件。某次它为了搞清楚一个配置文件,直接打开了~/.ssh/id_rsa.pub(还好只是公钥),这让我出了一身冷汗。
解决办法很简单:为每个 agent 会话绑定一个工作目录,默认就是当前项目根目录。所有文件读写在传递前都要经过一个路径校验函数,如果绝对路径不是工作目录或其子目录,拒绝执行并返回错误信息给模型。跨目录操作需要显式声明,例如在 prompt 里写清楚"你可以读取 /etc/hosts 但绝不能修改它"。
这个设计也让多项目并行成为可能。我开了三个 Emacs 框架(frame),分别对应三个项目,每个会话的 agent 相互独立,不会乱窜。
5.3 超时、并发上限与输出长度控制
最后一个容易忽视的环节是资源消耗控制。模型生成的命令可能是个死循环,也可能是故意拖延的构建任务。我在进程执行层加了三个保险:
- 命令默认 30 秒超时,超时强制
kill-process,返回 timeout 提示; - 同一时间只允许一个 shell 任务运行,新的调用排队等待,避免并发命令产生不可预期状态;
- 输出截断到最多 10 万字符,超过部分折叠为一个摘要信息。
这些限制可以防止"agent 看起来好像什么都不干但其实在后台疯狂消耗资源"的情况。我有一次深夜跑任务,agent 生成的命令里有个while true死循环,如果没有超时保护,可能要到第二天早上才会发现。加了这些护栏之后,类似事故没有再发生过。
6. 实测下来最值得复用的几个配方
最后分享几个我在日常使用中高频使用、且稳定复现的 agent 对话配方。每一个都可以直接复制到你的 agent-shell 里试试。
6.1 "重构当前缓冲区的 import 顺序"
用户:把这个文件里所有 import 按字母序重新排列,保持注释不变。这一步 agent 会先用read_file读取缓冲区内内容(我配置了可以让 agent 直接读取当前缓冲区,而不是磁盘文件,这样未保存的修改也能感知),然后用python3跑一段小脚本解析 import 区块并重排,最后write_file写回文件。整个过程大约 20 秒,比我自己手动整理快得多。
有一个细节要注意:如果文件很大(超过几百行),建议让 agent 先只看 import 区块附近的 30 行,避免它把整个文件读进上下文后生成过度修改。
6.2 "帮我写一个补丁并跑本地测试"
用户:给 src/parser.py 里的 parse_line 函数加一个支持空行的逻辑,然后跑 pytest test/test_parser.py,如果挂了就自动修一遍再跑。这是一个典型的迭代任务。agent 流程是:read_file读函数实现,write_file写入改动,shell跑测试,看到失败输出后回到第一步修改,再跑。我在 prompt 里加了一个规则:重复修改最多 3 次,超过次数就停止并汇报最可能的根因。这样不会无限循环卡在那,也不会把明明走偏的修改一直试下去。
实测效果:这种小规模单函数修改,agent 的成功率大概在八成左右;剩余两成通常是测试断言格式或环境问题导致,但即使失败了,agent 给的失败信息也帮我缩小了排查范围。
6.3 "总结这个项目的架构并生成文档"
用户:用五分钟快速看一遍这个项目的结构和核心依赖,生成一份 200 字的架构说明,存到 docs/architecture.md。这是最能体现"多工具协作"的配方。agent 会先用glob拿到整棵目录树,快速识别入口文件和配置文件(package.json、pyproject.toml、go.mod等),然后针对性地read_file三四个核心文件,最后生成 markdown 并写入。
我第一次跑这个时,agent 读了 11 个文件才写总结,耗时约 40 秒。我给 prompt 加了一条约束"只读取你判断为最关键的 5 个文件,其他用 glob 扫描来推断"之后,耗时降到 15 秒左右,质量几乎没有下降。这告诉我们:给 agent 限定工作规模,有时结果是更好的,因为限制迫使它抓重点。
7. 现在可以考虑的扩展方向
这个工作台用了一个多月后,我已经把文档写读、代码重构、日志分析、测试驱动这几类常规任务完全交给 agent 了。它并未取代我的判断力,但确实消灭了大量重复机械动作。尤其让我满意的是"进化"这个属性——每完成一次此前没做过的复杂任务,agent 就拥有了"曾经做过一次"的经验,下次再做就是轻车熟路。
如果你也要搭一个类似的自己工作台,我给你一个建议:不要一开始就贪多求全。先把read_file、shell、write_file这三个工具跑通,形成一个最小闭环,让 agent 能读文件、能跑命令、能改代码。一个功能闭环跑通后再考虑扩展。早期版本把工具做得越多,prompt 越冗余,调试时定位问题越费劲。
另外,强烈建议给 agent 的操作留一个撤销机制。我最初没在意这个问题,有次让 agent 改了配置文件结果没备份,折腾了很久才恢复。现在 agent-shell 会在每次write_file前自动生成一份.bak备份,避免改坏无法回退。这应该成为类似工具的标配。
搭好这个循环之后,你会感觉到自己的工作台确实在慢慢"进化"。隔一两周翻看一下配置目录里自动生成的工具函数,能看到它帮你沉淀了不少原本要反复操作的东西。这份沉甸甸的积累感,可能就是最让人继续折腾下去的理由。