序言
众所周知,common lisp 有一个 REPL,而与大模型进行多轮对话也需要一个“REPL”,因此我想看看是否有一种方式,可以将两个 REPL 整合到一起。比如说,我在一个 REPL 中既可以输入合法的 lisp 表达式,也可以输入不是合法代码的自然语言内容。前者会由 lisp 编译器负责求值,后者则是提交给大模型回答、调用工具。由于此时处于 lisp 进程内,因此大模型甚至可以使用工具改变 lisp 在内存中的状态,实现动态地修改运行中程序的逻辑。
而要做到这一切,需要迈出的第一步便是让 lisp 能够调用 LLM。下面便是我微不足道的摸索成果。
安装依赖
我找到了一个叫 cl-completions 的项目,决定用它来请求 minimax。首先要把这个库安装到本地,本来想用 quicklisp 来安装的,但是这个库并不支持——在包管理器方面,它只支持作者自己开发的 ocicl。
不过没关系,因为这个库也是用 asdf 来定义项目的,所以也可以手动放到 quicklisp 的 local-projects 目录下,就可以在 quicklisp 中加载成功了。
由于我本地用的是 roswell,因此要将 cl-completions 克隆到正确的目录下
git clone https://github.com/atgreen/cl-completions ~/.roswell/lisp/quicklisp/local-projects/completions/
这样就可以在新项目的 .asd 文件中引用到它了。
初始化项目结构
2026 年了,按照 Gemini 的建议,用 cl-project 来初始化这个 demo 项目的结构,运行下列代码
(cl-project:make-project #P"/Users/liutos/SourceCode/cl/cl-completion-demo/")
效果如下图所示
将这个项目做一个软链接到 local-projects 目录下
cd ~/.roswell/lisp/quicklisp/local-projects/
ln -s ~/SourceCode/cl/cl-completion-demo/ ./cl-completion-demo
现在就可以在 SLIME 中加载这个项目,然后增量式地开发了。
设置环境变量
用环境变量的方式来给 completions 这个库传入 LLM 的 api key。准备一个 .env 文件
然后用 cl-dotenv 这个库来加载它
(cl-dotenv:load-env #p"/Users/liutos/SourceCode/cl/cl-completion-demo/.env")
Hello World
来写 LLM 版本的 Hello World 程序吧。只需要跟 LLM 打声招呼再把结果打印出来
(defun greet ()
(let* ((completer
(make-instance 'completions:openai-completer
:api-key (uiop:getenv "OPENAI_API_KEY")
:endpoint "https://api.minimaxi.com/v1/chat/completions"
:model "MiniMax-M2.7"))
(response
(completions:get-completion completer
"hello, world!")))
(format t "~A~%" response)))
运行效果如下
使用工具
众所周知,OpenAI 的大模型是支持工具的,completions 库同样封装了这一功能。先用宏defun-tool定义工具,然后再用关键字参数:tools传递给模型感知。例如,定义一个获取今天的日期的工具的示例如下
(completions:defun-tool get-date ()
"获取今天的年月日。"
(let ((decoded-time (multiple-value-list (get-decoded-time))))
(format nil "~D-~D-~D"
(nth 5 decoded-time)
(nth 4 decoded-time)
(nth 3 decoded-time))))
defun-tool同时也注册了一个名为get-date的工具,调用函数completions:list-available-tools可以看到。注册完毕后,就需要将可用的工具传给大模型,然后开始提问让模型使用该工具了。
(defun greet ()
(let* ((completer
(make-instance 'completions:openai-completer
:api-key (uiop:getenv "OPENAI_API_KEY")
:endpoint "https://api.minimaxi.com/v1/chat/completions"
:model "MiniMax-M2.7"
:tools (mapcar #'intern (completions:list-available-tools))))
(response
(completions:get-completion completer
"今天是几月几号?")))
(format t "~A~%" response)))
运行一下试试——哎呀,出状况了
看起来是因为工具get-date是不需要参数的,所以参数列表为空所致。鉴于项目的 README 中也用无参的工具演示了一次,因此我怀疑这个是 minimax 的 bug。没关系,可以先将get-date改造为带有一个参数的函数来 workaround
(completions:defun-tool get-date ((placeholder string "固定传入 1 即可。"))
"获取今天的年月日。"
(declare (ignorable placeholder))
(let ((decoded-time (multiple-value-list (get-decoded-time))))
(format nil "~D-~D-~D"
(nth 5 decoded-time)
(nth 4 decoded-time)
(nth 3 decoded-time))))
这下工具传递正确、使用成功了
human in the loop
尽管我前面的例子中只给函数get-completion传入了一个字符串,但其实这只是库做了一点点封装,本质上是传入了
'((("role" . "user") ("content" . "今天是几月几号?")))
因此只要把模型的返回和新的提问与历史内容追加到一起,就可以实现多轮对话的效果了。改造后的greet如下
(defun greet ()
(let ((completer
(make-instance 'completions:openai-completer
:api-key (uiop:getenv "OPENAI_API_KEY")
:endpoint "https://api.minimaxi.com/v1/chat/completions"
:model "MiniMax-M2.7"
:tools (mapcar #'intern (completions:list-available-tools))))
(messages '((("role" . "user")
("content" . "今天是几月几号?")))))
(loop
(multiple-value-bind (response updated-messages)
(completions:get-completion completer
messages)
(format t "AI:~A~%" response)
(format t "我:")
(force-output)
(let ((user-input (read-line)))
(setf messages
(append updated-messages
`((("role" . "user")
("content" . ,user-input))))))))))
效果如下图所示
加载配置文件
为了不需要额外的 shell 脚本来加载.env文件,新增一个函数来做这个事情
(defun load-environment ()
"从 .env 文件中加载配置文件。"
(let ((env-file (asdf:system-relative-pathname 'cl-completion-demo ".env")))
(cl-dotenv:load-env env-file)))
然后在函数greet的最开始调用
;; 加载 minimax 的 api key 配置。
(load-environment)
脱离 REPL 运行
为了可以不用每次都打开 Emacs 来运行这个程序,将其制作成可以独立运行的可执行文件。这要修改.asd文件,示例代码如下
(defsystem "cl-completion-demo"
:version "0.1.0"
:author ""
:license ""
:depends-on (:completions
#:cl-dotenv)
:components ((:module "src"
:components
((:file "main"))))
:description ""
:in-order-to ((test-op (test-op "cl-completion-demo/tests")))
:build-operation "program-op"
:build-pathname "cl-completion-demo"
:entry-point "cl-completion-demo:greet")
然后在项目目录中运行命令:
ros run --eval '(asdf:make :cl-completion-demo)' --quit
这样就可以得到一个脱离 Emacs 的、可独立运行的可执行文件了。文件体积也只有区区 80MB 而已。
结语
本文出现的所有代码均采用古法编程诚心手敲,童叟无欺。
全文完。