
在 Emacs 里浏览网页很多人第一反应是折腾半天还不如直接切到 Chrome。确实EWW 这类内置浏览器能打开页面但遇到广告密集、正文混乱的长文章阅读体验几乎劝退。不过现在情况正在变化LLM 的能力越来越容易通过 HTTP API 接入我们完全可以把“理解网页”这件事交给大模型让 Emacs 浏览器重新变得实用。本文将围绕“用 LLM 增强 Emacs 内置浏览器 EWW”展开核心目标是让 EWW 具备网页摘要、关键信息提取、页面问答等能力。无论你是 Emacs 新手还是老用户看完这篇文章后都能仿照示例自己给 EWW 装上“AI 阅读助手”。整个方案不依赖特殊框架代码直接复制可用核心接口以 OpenAI 兼容协议为例同时覆盖 Ollama 本地模型接入方式。1. 为什么要在 Emacs 浏览器里接入 LLM1.1 EWW 浏览器的现状与痛点EWWEmacs 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 里浏览网页的几种方式在写代码之前先明确本文选择的浏览器环境方式特点是否推荐EWWEmacs 自带纯 Elisp跨平台适合提取文本推荐w3m依赖外部 w3m 程序渲染快但不美观可选xwidget-webkit嵌入真实 WebKit 内核体验接近 Chrome但依赖系统 GUI可选本文以 EWW 为主因为它是 Emacs 内置的不需要额外安装外部程序而且 EWW 缓冲区中就是可直接读取的文本内容非常适合交给 LLM 处理。另外EWW 在 Emacs 27 之后已经足够稳定可以满足日常文本浏览。2.2 大模型接口的选择当前 LLM 服务基本分成两类云端 APIOpenAI、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 构造摘要 PromptLLM 的输出质量和 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 UnauthorizedAPI Key 错误确认 Key 是否有效服务商是否要求 Bearer 格式返回 404 Not FoundAPI URL 不对确认模型名称与端点是否匹配Ollama 地址是否带了/v1返回 400 Bad Request请求体格式错误或模型名错误用my-llm-build-request-body手动生成 JSON 并检查json-parse-string 报错响应内容不是合法 JSON可能是 API 返回了错误信息建议先打印原始 JSON 文本中文乱码请求体或响应编码问题使用encode-coding-string编码请求数据并设置Content-Type为 UTF-8Emacs 长时间卡住使用了同步请求且网络慢替换成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两个变量核心函数完全通用。如果你在实际配置中遇到了其他问题欢迎在评论区留言。