在 Emacs 里浏览网页,很多人第一反应是折腾半天还不如直接切到 Chrome。确实,EWW 这类内置浏览器能打开页面,但遇到广告密集、正文混乱的长文章,阅读体验几乎劝退。不过现在情况正在变化:LLM 的能力越来越容易通过 HTTP API 接入,我们完全可以把“理解网页”这件事交给大模型,让 Emacs 浏览器重新变得实用。
本文将围绕“用 LLM 增强 Emacs 内置浏览器 EWW”展开,核心目标是让 EWW 具备网页摘要、关键信息提取、页面问答等能力。无论你是 Emacs 新手还是老用户,看完这篇文章后都能仿照示例,自己给 EWW 装上“AI 阅读助手”。整个方案不依赖特殊框架,代码直接复制可用,核心接口以 OpenAI 兼容协议为例,同时覆盖 Ollama 本地模型接入方式。
1. 为什么要在 Emacs 浏览器里接入 LLM
1.1 EWW 浏览器的现状与痛点
EWW(Emacs Web Wowser)是 Emacs 自带的纯 Elisp 浏览器,它的核心优势是轻量、高度可定制、和 Org-mode 等工具链无缝衔接。但相比现代浏览器,EWW 有几个比较明显的短板:
- 对 JavaScript 支持很弱,很多网页打开后布局丢失。
- 广告、导航栏、无关推荐和正文混在一起,可读性差。
- 缺少“阅读模式”级别的信息提炼能力,用户只能自己在大段文本里找重点。
- 交互单一,不能对页面内容做进一步的搜索、翻译或总结。
所以,在很多人的体验中,EWW 更像“应急查看工具”,而不是日常阅读工具。这个痛点不是 Emacs 的缺陷,而是文本浏览器在信息爆炸时代天然存在的局限。
1.2 LLM 能补齐什么能力
大语言模型最擅长的,恰好是“从一段混乱文本中提取重点、整理结构、回答问题”。把 LLM 接入 EWW 之后,我们可以实现以下能力:
- 一键生成当前网页的摘要,快速判断文章是否值得细读。
- 针对当前页面提问,例如“这篇文章的核心结论是什么”“作者提到了哪几种方案”。
- 提取页面中的代码、链接、表格或联系人信息。
- 翻译页面关键段落,减少语言障碍。
- 把浏览到的内容整理成学习笔记,沉淀到个人知识库。
这些能力本质上都遵循同一个流程:从 EWW 缓冲区中抽取文本,构造 Prompt,发送给 LLM,再把返回结果展示出来。只要打通这条链路,Emacs 浏览器就从“显示 HTML 的终端工具”变成了“理解网页的信息助手”。
1.3 这个方案的适合人群
如果你是前端、后端、算法或运维工程师,平时已经在终端里用 Emacs 写代码,希望在读技术文档时更高效,那么这套方案会非常适合。你不需要精通 HTTP 协议,也不需要了解大模型内部原理,只需要掌握基本的 Elisp 函数写法,即可把 LLM 能力集成到日常浏览流程里。
2. 技术选型与核心思路
2.1 Emacs 里浏览网页的几种方式
在写代码之前,先明确本文选择的浏览器环境:
| 方式 | 特点 | 是否推荐 |
|---|---|---|
| EWW | Emacs 自带,纯 Elisp,跨平台,适合提取文本 | 推荐 |
| w3m | 依赖外部 w3m 程序,渲染快但不美观 | 可选 |
| xwidget-webkit | 嵌入真实 WebKit 内核,体验接近 Chrome,但依赖系统 GUI | 可选 |
本文以 EWW 为主,因为它是 Emacs 内置的,不需要额外安装外部程序,而且 EWW 缓冲区中就是可直接读取的文本内容,非常适合交给 LLM 处理。另外,EWW 在 Emacs 27 之后已经足够稳定,可以满足日常文本浏览。
2.2 大模型接口的选择
当前 LLM 服务基本分成两类:
- 云端 API:OpenAI、Anthropic、国内的通义千问、DeepSeek、Kimi 等厂商都提供 API 服务。很多厂商提供了 OpenAI 兼容接口,这意味着我们只需要写一套请求代码,改一下 URL、密钥和模型名,就能切换不同服务商。
- 本地模型:Ollama、LM Studio 等工具可以把开源模型跑在本机。以 Ollama 为例,它提供了
/v1/chat/completions这样的 OpenAI 兼容端点,适合对数据隐私要求高的场景。
为了避免把文章写成某一家的广告,本文采用“OpenAI 兼容协议”作为统一标准。这样读者可以根据自己的需求,自由选择云端模型或本地模型。
2.3 整体流程拆解
整个方案可以拆成四个步骤:
- 从当前 EWW buffer 中提取网页文本,并做长度截断。
- 构造一个包含“指令 + 网页文本”的 Prompt。
- 通过 HTTP 请求调用 LLM API,获取生成结果。
- 把结果展示在独立缓冲区中,或者替换当前页面内容。
为了不让 Emacs 界面在等待 API 时卡住,我们应该优先使用异步请求。这里我会给出同步版本和异步版本两套实现,分别说明适用场景。
3. 环境准备与配置
3.1 基础环境要求
本文示例适用的环境为:
- Emacs 27 或更高版本。因为代码中会用到一个原生 JSON 解析函数
json-parse-string,这个函数在 Emacs 27 之后才内置。 - 可以访问 LLM API 的网络环境。如果使用本地 Ollama,则不需要外网。
- 一个可用的 API Key,或本地安装好 Ollama。
需要特别说明的是,不同 Emacs 版本、不同 LLM 服务商在细节上可能存在差异。版本需要根据你的项目实际情况调整,本文示例以常见环境为例,重点演示配置思路。
3.2 安装必要依赖
我们会用到 Emacs 内置的url库,它提供了url-retrieve-synchronous和url-retrieve两个函数,分别用于同步和异步 HTTP 请求。这两个函数不需要额外安装包,Emacs 自带。
如果你更习惯使用plz这样的现代 HTTP 客户端库,也可以通过M-x package-install RET plz RET安装。本文为了降低入门门槛,主要使用内置url库,这样读者在任意一台安装了 Emacs 的机器上都可以直接执行。
3.3 在 init.el 中配置全局变量
首先创建几个全局变量,统一管理 API 地址、密钥和模型名称。这样后续换模型时,只需要改一处。
;; 如果使用云端 OpenAI 兼容服务,这里填服务商提供的地址 (defvar my-llm-api-url "https://api.openai.com/v1/chat/completions") ;; API Key。建议优先从环境变量读取,避免硬编码在配置里 (defvar my-llm-api-key (or (getenv "LLM_API_KEY") "")) ;; 模型名称,根据服务商实际提供情况填写 (defvar my-llm-model "gpt-4o-mini") ;; 每次发送给大模型的网页文本最大字符数,防止请求体过大 (defvar my-llm-max-context-length 6000)如果你使用 Ollama 本地模型,配置可以改成:
(setq my-llm-api-url "http://localhost:11434/v1/chat/completions") (setq my-llm-model "qwen2.5:7b") (setq my-llm-api-key "ollama")这里有几个注意事项:
- 不要把 API Key 直接写进 init.el 后上传公开仓库。环境变量配合
.authinfo是更稳妥的方式。 - 不同的服务商对不同模型的 context 长度限制不同。6 千字符只是一个保守值,读者可以根据实际模型调大或调小。
- Ollama 默认监听 11434 端口。如果修改过端口,需要同步修改 URL。
4. 编写核心请求函数:向 LLM 发送消息
4.1 构造 JSON 请求体
OpenAI 兼容协议的请求格式如下:
{ "model": "gpt-4o-mini", "messages": [ { "role": "user", "content": "你好,请帮我总结这段文本" } ] }在 Elisp 中,我们可以用json-encode把 Lisp 对象转换成 JSON 字符串。注意messages是一个数组,所以要用vector表示,而数组中的每个元素是对象,应该用 alist 表示:
(defun my-llm-build-request-body (user-prompt) "构造发送给 LLM 的 JSON 请求体字符串。" (json-encode `((model . ,my-llm-model) (messages . [((role . "user") (content . ,user-prompt))]))))这个函数看起来简单,但非常关键。它把 Elisp 数据结构正确转换成接口识别的 JSON 格式,避免我们在每个函数里重复拼接字符串。
4.2 同步请求函数
同步请求使用url-retrieve-synchronous。优点是代码简单,拿到结果后直接返回;缺点是请求期间 Emacs 会阻塞,不适合在大量交互中使用。不过作为入门版本,它是理解整个流程最好的起点。
(defun my-llm-chat-sync (user-prompt) "同步方式向 LLM 发送 USER-PROMPT,返回模型生成的文本。" (let* ((url-request-method "POST") (url-request-extra-headers `(("Content-Type" . "application/json") ("Authorization" . ,(concat "Bearer " my-llm-api-key)))) (url-request-data (encode-coding-string (my-llm-build-request-body user-prompt) 'utf-8)) (response-buffer (url-retrieve-synchronously my-llm-api-url))) (unless response-buffer (error "LLM 请求失败:未获取到响应")) (with-current-buffer response-buffer (goto-char (point-min)) ;; 跳过 HTTP 响应头,HTTP 头与正文之间有一个空行 (re-search-forward "^$" nil t) (let* ((json-body (buffer-substring-no-properties (point) (point-max))) (response (json-parse-string json-body 'plist 'list)) (choices (plist-get response :choices)) (first-choice (car choices)) (message-plist (plist-get first-choice :message)) (content (plist-get message-plist :content))) (kill-buffer response-buffer) (string-trim content)))))这个函数有几个细节需要解释:
url-request-method设置为"POST",因为聊天补全接口要求 POST 请求。url-request-data需要是经过编码的字节串,直接用encode-coding-string转成 UTF-8,可以避免中文乱码。- 响应缓冲区中前几行是 HTTP 响应头,所以使用
re-search-forward "^$"定位到空行,空行之后的才是 JSON 响应体。 json-parse-string的第二个参数'plist表示按 plist 方式解析 JSON 对象,第三个参数'list表示数组也解析为 list,方便我们直接用car取出第一个元素。- 拿到
content后,用string-trim去掉首尾空白。
为了验证这段代码,可以在*scratch*缓冲区中执行:
(my-llm-chat-sync "你好,请用一句话介绍你自己")如果 API Key 和网络配置正确,你会看到返回一段模型生成的文本。
4.3 异步请求函数
同步请求虽然简单,但遇到网络波动或长文本生成时,Emacs 界面会长时间无响应。实际项目中,我更推荐使用异步版本:
(defun my-llm-chat-async (user-prompt callback) "异步向 LLM 发送 USER-PROMPT,请求完成后调用 CALLBACK,参数为结果文本。" (let ((url-request-method "POST") (url-request-extra-headers `(("Content-Type" . "application/json") ("Authorization" . ,(concat "Bearer " my-llm-api-key)))) (url-request-data (encode-coding-string (my-llm-build-request-body user-prompt) 'utf-8))) (url-retrieve my-llm-api-url (lambda (status) (unless (and status (plist-get status :error)) (goto-char (point-min)) (re-search-forward "^$" nil t) (let* ((json-body (buffer-substring-no-properties (point) (point-max))) (response (condition-case err (json-parse-string json-body 'plist 'list) (error (list :error (error-message-string err))))) (choices (plist-get response :choices)) (first-choice (car choices)) (message-plist (plist-get first-choice :message)) (content (plist-get message-plist :content))) (kill-buffer (current-buffer)) (when content (funcall callback (string-trim content)))))))))异步版本把结果通过回调函数返回。这样做的好处是,Emacs 可以在等待 API 响应时继续处理其他命令,用户不会觉得“死机”了。你可以在请求前用message显示“正在请求 LLM”,在回调里更新状态,交互体验会更好。
5. 实战:让 EWW 一键生成网页摘要
5.1 从当前 EWW 缓冲区提取文本
要分析当前浏览的网页,首先要把 EWW buffer 中的内容取出来。这里需要区分“整个缓冲区的 HTML 渲染文本”和“网页的原始 HTML”。EWW 渲染完成后,缓冲区里已经是可读的文本内容,所以我们直接获取buffer-string即可。
(defun my-eww-extract-text () "提取当前 EWW buffer 的文本,并做长度限制。" (interactive) (let ((raw-text (buffer-substring-no-properties (point-min) (point-max)))) ;; 把连续的换行和 Tab 替换成空格,减少 Prompt 中的噪声 (setq raw-text (replace-regexp-in-string "[\n\t]+" " " raw-text)) (when (> (length raw-text) my-llm-max-context-length) (setq raw-text (substring raw-text 0 my-llm-max-context-length))) raw-text))如果你希望提取更干净的文章正文,可以配合 EWW 自带的可读视图命令eww-readable。不过eww-readable会直接修改当前 buffer,实际使用时建议先复制一份到临时 buffer 再操作。这里我们以简单文本提取为主,保证核心流程能跑通。
5.2 构造摘要 Prompt
LLM 的输出质量和 Prompt 关系很大。摘要场景下,我会在 Prompt 中明确:
- 角色:你是一位擅长信息提炼的阅读助手。
- 任务:给用户提供一篇文章的摘要。
- 输出格式:用中文返回,包含核心观点、关键结论、可能存在的行动建议。
- 上下文:将网页文本放在最后。
(defun my-eww-build-summarize-prompt (page-text) "构造网页摘要 Prompt。" (format (concat "你是一位擅长信息提炼的阅读助手。" "请阅读下面的网页文本,并用中文输出摘要。\n" "摘要需包含:\n" "1. 文章主题\n" "2. 核心观点\n" "3. 关键结论\n" "请使用简洁的要点式输出,不要联系其他知识。\n\n" "网页文本如下:\n%s") page-text))这里特意加上“不要联系其他知识”,是为了避免模型把网页之外的经验混进摘要,保持“基于当前页面”的边界。
5.3 一键摘要命令
把前面的函数串起来,写成一个交互式命令:
(defun my-eww-llm-summarize () "对当前 EWW 页面内容生成摘要。" (interactive) (unless (eq major-mode 'eww-mode) (error "当前缓冲区不是 EWW 页面")) (let ((page-text (my-eww-extract-text))) (message "正在请求 LLM 生成网页摘要...") (my-llm-chat-async (my-eww-build-summarize-prompt page-text) (lambda (result) (my-llm-result-show "网页摘要" result) (message "网页摘要生成完成")))))为了让结果更直观,我再补充一个结果展示函数。它会把 LLM 返回的文本放入一个独立缓冲区,方便阅读和保存:
(defun my-llm-result-show (title content) "在独立缓冲区中展示 LLM 返回的结果。" (let ((buffer (get-buffer-create "*LLM Result*"))) (with-current-buffer buffer (let ((buffer-read-only nil)) (erase-buffer) (insert (format "%s\n\n%s\n" title content)) (goto-char (point-min)) (read-only-mode 1))) (display-buffer buffer)))现在,在 EWW 中打开任意一篇文章,执行M-x my-eww-llm-summarize,稍等片刻,就能在*LLM Result*缓冲区中看到摘要结果。
5.4 给 EWW 绑定快捷键
每次输入M-x my-eww-llm-summarize确实有点费劲。我们可以把命令绑定到 EWW 的按键映射中:
(add-hook 'eww-mode-hook (lambda () (local-set-key (kbd "C-c s") #'my-eww-llm-summarize) (local-set-key (kbd "C-c q") #'my-eww-llm-query)))绑定后,在 EWW 页面中直接按C-c s即可生成摘要。C-c q对应的查询函数,我们接下来实现。
6. 进阶:基于当前网页内容进行问答
6.1 页面问答的基本逻辑
摘要只是 LLM 能力的一部分。很多时候,我们打开一篇技术博客,只是想确认“这个方案需要什么依赖”“作者最后推荐了哪个库”。如果整篇读完,成本很高。此时,基于当前页面内容的问答就非常有价值。
问答的本质和摘要一样,只是 Prompt 换成“根据网页内容回答用户问题”。我们还需要接收用户输入,这时可以用interactive提示用户输入字符串。
(defun my-eww-llm-query (question) "基于当前 EWW 页面内容回答用户问题。" (interactive "s请输入你想针对当前页面提出的问题:") (unless (eq major-mode 'eww-mode) (error "当前缓冲区不是 EWW 页面")) (let* ((page-text (my-eww-extract-text)) (prompt (format (concat "请基于下面的网页文本回答用户问题。\n" "要求:\n" "1. 只能使用网页中出现的信息,不要编造\n" "2. 如果网页中找不到答案,直接说明\n" "3. 使用中文回答\n\n" "网页文本:\n%s\n\n" "用户问题:%s") page-text question))) (message "正在基于当前页面内容回答...") (my-llm-chat-async prompt (lambda (result) (my-llm-result-show question result) (message "回答生成完成")))))这个命令的好处是,它把“读博客”变成“问博客”。当你关注的问题比较具体时,效率提升非常明显。
6.2 让大模型帮忙提取代码块
技术类网页中常常包含代码。如果你只想拿到文中某段代码,可以继续扩展 Prompt。比如:
(defun my-eww-llm-extract-code () "提取当前 EWW 页面中的代码示例。" (interactive) (unless (eq major-mode 'eww-mode) (error "当前缓冲区不是 EWW 页面")) (let ((page-text (my-eww-extract-text))) (my-llm-chat-async (format (concat "请从下面的网页文本中提取所有代码块," "并按照原始语言返回。\n" "如果存在多个代码块,请按顺序列出。\n\n%s") page-text) (lambda (result) (my-llm-result-show "网页代码提取结果" result) (message "代码提取完成")))))实际使用中,你可以把my-eww-llm-query做成一个“通用 Prompt”入口,通过前缀参数切换不同任务,但本文就不做过度设计了。
6.3 页面内容太长时怎么处理
真实网页动辄上万字符,远超模型上下文窗口。本文示例用my-llm-max-context-length截断,但简单截断可能丢失重要信息。更稳妥的做法是:
- 先尝试用
eww-readable生成可读视图,再提取文本。 - 把网页按照段落切分,分段生成摘要,再汇总。
- 使用支持更长上下文的模型,例如 128K 上下文版本。
第一阶段我们先用截断方案让流程跑通,后续可以针对阅读场景做更细的分段策略。
7. 常见问题与排查清单
在接入 LLM 的过程中,最容易出错的往往不是 Elisp 语法,而是 API 请求和 JSON 解析。下面整理了一些高频问题。
| 问题现象 | 常见原因 | 排查与解决思路 |
|---|---|---|
| 请求后没有反应 | API Key 没配置或网络不通 | 检查my-llm-api-key是否为空,message中是否有报错信息 |
| 返回 401 Unauthorized | API Key 错误 | 确认 Key 是否有效,服务商是否要求 Bearer 格式 |
| 返回 404 Not Found | API URL 不对 | 确认模型名称与端点是否匹配,Ollama 地址是否带了/v1 |
| 返回 400 Bad Request | 请求体格式错误或模型名错误 | 用my-llm-build-request-body手动生成 JSON 并检查 |
| json-parse-string 报错 | 响应内容不是合法 JSON | 可能是 API 返回了错误信息,建议先打印原始 JSON 文本 |
| 中文乱码 | 请求体或响应编码问题 | 使用encode-coding-string编码请求数据,并设置Content-Type为 UTF-8 |
| Emacs 长时间卡住 | 使用了同步请求且网络慢 | 替换成my-llm-chat-async异步版本 |
| 摘要内容偏离网页 | Prompt 约束不够明确 | 调整 Prompt,增加“只能基于给定文本回答”的约束 |
如果你遇到没有列出的问题,推荐按下面的顺序排查:
- 用
curl先手动请求一次接口,确认 API 地址、请求头、请求体是否正确。 - 在 Emacs 中执行
(message "%s" (my-llm-build-request-body "测试")),确认生成的 JSON。 - 打印
response-buffer的内容,查看真实响应。 - 检查 Emacs 版本是否满足
json-parse-string的内存要求。
这里额外强调一个容易踩坑的点:很多用户会把url-request-data直接写成普通字符串,导致中文被错误编码。正确做法是先encode-coding-string成 UTF-8 字节串,再赋值给url-request-data。
8. 最佳实践与工程建议
8.1 敏感信息保护
如果你浏览的页面包含内部文档、密钥、个人信息等内容,不要把整页文本发送给外部云端 API。即使是公开网页,也要考虑服务商的数据留存政策。更稳妥的做法是:
- 使用 Ollama 或本地模型处理敏感内容。
- 发送前过滤掉明显的账号、密码、Token 等模式。
- 在 Prompt 中明确“不要在答案中复述页面里的 API Key”。
8.2 API 成本控制
LLM API 按 token 计费,网页文本越多,成本越高。工程上可以从三个角度控制成本:
- 限制
my-llm-max-context-length,优先发送正文核心部分。 - 先本地判断页面是否值得调用 LLM,比如文章长度小于一定阈值时直接跳过。
- 对同一页面使用缓存,避免反复调用。
8.3 超时与错误处理
异步请求虽然不会卡死界面,但网络异常时回调函数可能接收不到正常结果。建议在回调中处理status参数,并在失败时用message或warn提示用户。同时可以给url-retrieve加上超时控制函数,避免请求悬挂。
一个简单的做法是:
(defvar my-llm-timeout-seconds 30) (defun my-llm-chat-async-with-timeout (prompt callback) "带超时控制的异步 LLM 请求。" (let ((timer (run-with-timer my-llm-timeout-seconds nil (lambda () (message "LLM 请求超时,请检查网络"))))) (my-llm-chat-async prompt (lambda (result) (cancel-timer timer) (funcall callback result)))))这个示例提供了一种加超时思路,实际生产环境可以结合plz的:else回调处理更完整的错误链路。
8.4 把摘要沉淀成个人知识库
目前很多开发者都在尝试把零散网页内容整理成个人知识库,例如使用 Obsidian + LLM 构建第二大脑。Emacs 的优势在于,我们可以把*LLM Result*缓冲区的内容直接写入 Org-mode 文件,形成结构化的阅读笔记。
举个例子:摘要生成后,按C-c C-o之类快捷键,把结果追加到指定 Org 文件。这种做法本质上是 Andrej Karpathy 提到的“LLM Wiki”范式的简化版:让大模型把信息整理成交互性更强的文档,再沉淀成个人索引。浏览器不再只是阅读入口,更进一步成为个人知识采集器。
8.5 让工具可组合
不要把代码写成一堆彼此孤立的命令。建议把“提取网页文本”“调用 LLM”“展示结果”“保存笔记”拆成独立的函数,这样未来可以组合出更多玩法,例如:
- 定时抓取某个页面并生成摘要。
- 把多个网页摘要合并成一份周报。
- 在 kill-ring 中直接插入摘要结果。
9. 总结与后续学习方向
本文围绕“如何让 LLM 增强 Emacs 内置浏览器”这个话题,完整走了一遍从环境配置、核心请求函数到 EWW 摘要、页面问答的实战流程。你可以直接复制文中的 Elisp 代码,在本地跑通一个最小可用的“EWW + LLM 阅读助手”。关键点包括:
- 使用 OpenAI 兼容接口,统一不同模型服务商的调用方式。
- 使用 Emacs 内置
url库完成 HTTP 请求,避免安装额外依赖。 - 优先使用异步请求,保证 Emacs 界面不被阻塞。
- 通过 Prompt 约束模型“只基于当前网页内容回答”,避免信息越界。
- 关注隐私和成本,敏感页面改走本地模型。
下一步,建议你尝试把摘要结果保存到 Org-mode 文件,完善你的个人知识库工作流。也值得研究一下eww-readable的正文提取逻辑,对比它和直接截取 buffer 文本的差异。如果你想接入更多模型,只需修改my-llm-api-url、my-llm-model两个变量,核心函数完全通用。
如果你在实际配置中遇到了其他问题,欢迎在评论区留言。