emacs-在org文件中利用ob-gptel保存调用LLM聊天对话(deepseek为例)
1. 前言
我用AI用得比较少,也就网页白嫖一下Google AI Studio的Gemini,或者玩一玩免费的DeepSeek网页版。最近DeepSeek发布了新版本,在保持低廉价格的同时可用性也仅次于几家闭源大模型了,不得不多用用了。
但是DeepSeek的网页版使用起来,感觉不如谷歌的AI Studio。后者将每场对话当做一个纯文本文件,可以自由编辑删减先前的用户输入、AI输出,而DeepSeek中用户的历史输入和模型的历史输出都是固定的、无法修改的,缺失了很大的灵活性。
幸好,我们有Emacs,可以充分自定义整个体验。
:今日发现 deepseek 的网页版也可以编辑用户发送内容了,会并行开一条新回答分支。现在只有一些不方便在网页上进行的提问会使用API
:有人测试发现网页版限制修改次数了:
大约在2026年5月29日17点左右的时候,DeepSeek官网与APP端的修改输入次数与重复生成次数上限都被大幅下调,根据我的实际测试,为专家模式3次,快速模式6次,识图与快速模式相同为6次,目前直至21点13分仍旧没有恢复。
还是继续用API吧。
2. 软件配置
3. gptel
配置 gptel 。首先是变量设置环节。我们设置使用外部的 curl 用于发送网络请求。内置的 url-retrieve 总会有一些大大小小的问题。
(use-package gptel :demand t
:custom
(gptel-use-curl t)
gptel-directives 是一整套系统提示词,你可以把需要重复使用的提示词写在里面,在 gptel-menu 中快捷切换。我这边就默认留空了。
(gptel-directives
'((default . "")))
设置自动移动光标。如果你在gptel的buffer中对话,默认情况下,模型的输出插入完毕后光标会停留在你发送的位置。通过以下设置自动跳转光标
:hook
(gptel-post-response-functions . gptel-end-of-response)
设置模型。首先在windows上,需要专门配置一下 curl
:config
(when (eq system-type 'windows-nt)
(setq gptel-curl-extra-args '("--ssl-no-revoke")))
开始配置deepseek模型。这里配置默认使用 deepseek-v4-pro 模型。记得将 YOUR-DEEPSEEK-KEY 替换为你自己的api密钥。
(setq gptel-model "deepseek-v4-pro"
gptel-backend (gptel-make-openai "DeepSeek"
:host "api.deepseek.com"
:endpoint "/chat/completions"
:stream t
:key "YOUR-DEEPSEEK-KEY"
:models '("deepseek-v4-flash" "deepseek-v4-pro")))
接下来配置一些预设,可以等会通过 :preset 启用。
这里设置了两个预设, max 用于配置思考深度为max(默认high),而 no-r 用于关闭flash模型的思考。
(gptel-make-preset 'max
:request-params '(:reasoning_effort "max"))
(gptel-make-preset 'no-r
:request-params '(:thinking '(:type "disabled"))))
4. 2026年5月11日-说明
经测试,在启用 gptel 的分支功能情况下
;; 启用分支功能
(setq gptel-org-branching-context t)
(setf (alist-get 'org-mode gptel-prompt-prefix-alist) "@user\n")
(setf (alist-get 'org-mode gptel-response-prefix-alist) "@assistant\n")
;; https://github.com/karthink/gptel/discussions/1173 作者的删除回复中org标题函数
(defun my/gptel-remove-headings (beg end)
(when (derived-mode-p 'org-mode)
(save-excursion
(goto-char beg)
(while (re-search-forward org-heading-regexp end t)
(forward-line 0)
(delete-char (1+ (length (match-string 1))))
(insert-and-inherit "*")
(end-of-line)
(skip-chars-backward " \t\r")
(insert-and-inherit "*")))))
(add-hook 'gptel-post-response-functions #'my/gptel-remove-headings)
gptel 会将所有上下文视作一次单独的用户输入,如果里面有LLM的回复,则会被单独作为回复打包发送。
因此,假设有一个对话是这样:
* test ** test2 *** test32
则在「test32」后调用 gptel-send 时,发送的内容为:
[
{
"role": "system",
"content": ""
},
{
"role": "user",
"content": "* test\n** test2\n*** test32\n你好"
}
]
而等待AI回复后,假设是这样:
* test ** test2 *** test32 你好!我看到你发的测试内容了 **** test4
则在「test4」后调用 gptel-send 时,发送的内容为:
[
{
"role": "system",
"content": ""
},
{
"role": "user",
"content": "* test\n** test2\n*** test32\n你好"
},
{
"role": "assistant",
"content": "你好!我看到你发的测试内容了"
},
{
"role": "user",
"content": "**** test4"
}
]
这种方式比 ob-gptel 的 session 功能灵活许多,可以考虑优先使用。但缺点也比较明显:一是必须一发一答,否则会将前面的所有内容拼接成单次用户输入,缓存命中可能就会变低;二是org的标题层级星号前缀会被保留,导致在长期对话中可能出现层级问题。不过如果不需要分叉,也没有必要另开一个新的标题吧,直接继续对话即可。
:注意,不开启 gptel-mode 的情况下,文本属性不会持久化保存。这就意味着关闭重启之后,会把先前所有记录作为纯文本的单次用户输入发送给LLM,导致上下文缓存可能丢失,或者需要更多次来重建。如果需要重启后依旧保持对话轮次,最好还是使用 ob-gptel 。
:经检测,需要开启minor-mode gptel-mode 才可以持久化保存对话边界。
我们需要在对应 org 文件的第一行加上 # -*- eval: (gptel-mode 1); -*- ,再重新加载这个文件,这样就可以让 gptel-mode 帮我们自动保存哪些是用户输入哪些是 LLM 的回复了,即使重启 Emacs 也没有任何问题,可以正确识别。
5. ob-gptel
配置 ob-gptel 。由于该包还未上melpa,我们只能从对方的repo手动下载了。下面的代码适用于30之后的emacs,我设置了该包在 org 加载完成后立即载入。
(use-package ob-gptel :after org :demand t
:vc (:url "git@github.com:jwiegley/ob-gptel"
:rev :newest)
把 gptel 加入 org-babel 的可执行语言中:
:config
(add-to-list 'org-babel-load-languages '(gptel . t))
修复一个小问题:当光标在 end_src 的 c 之后时,按 C-c C-c 执行代码的时候,会将本代码块重复纳入对话上下文中。解决方法也很简单,直接在寻找session函数前执行一下 org-backward-element 即可。
(defun my/ob-gptel-exclude-current-block (orig-fun &rest args)
"Adjust point so that the current block is not included in session history."
(save-excursion
(org-backward-element)
(apply orig-fun args)))
(advice-add 'ob-gptel-find-session :around #'my/ob-gptel-exclude-current-block))
6. 使用例
请参考ob-gptel的README。这里举例最基础用法:
基本查询
#+begin_src gptel
What is the capital of France?
#+end_src
#+RESULTS:
The capital of France is Paris.
切换模型
#+begin_src gptel :model deepseek-v4-flash
Write a haiku about Emacs.
#+end_src
:session 参数会收集所有共享相同会话名称的前置代码块。这里启用了 :dry-run yes ,让我们可以先不把内容发送给LLM,而是在 RESULTS 中提前预览一下
#+begin_src gptel :session my-chat
Tell me about Emacs.
#+end_src
#+RESULTS:
假设这是第一个输入的模型回复
#+begin_src gptel :session my-chat :dry-run yes
What about its history?
#+end_src
(:model "deepseek-v4-pro" :messages
[(:role "user" :content "Tell me about Emacs.")
(:role "assistant" :content "假设这是第一个输入的模型回复")
(:role "user" :content "What about its history?")]
:stream :json-false :temperature 1.0)
如果你已配置了 gptel 预设,可以直接使用它们:
#+begin_src gptel :preset no-r
Explain monads simply.
#+end_src
7. 配置文件
(files--ensure-directory my/desktop-path)
(let ((file "gptel-config.txt"))
(org-babel-tangle-file (buffer-file-name) (concat my/desktop-path file) "elisp")
(format "[[https://blog.prayhand13013.top/%s][一键查看上述配置代码汇总]]" file))