时代在召唤——common lisp 调用 LLM

0 阅读5分钟

序言

众所周知,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/")

效果如下图所示

cl-project初始化项目.png

将这个项目做一个软链接到 local-projects 目录下

cd ~/.roswell/lisp/quicklisp/local-projects/
ln -s ~/SourceCode/cl/cl-completion-demo/ ./cl-completion-demo

现在就可以在 SLIME 中加载这个项目,然后增量式地开发了。

设置环境变量

用环境变量的方式来给 completions 这个库传入 LLM 的 api key。准备一个 .env 文件

dotenv文件的内容.jpg

然后用 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)))

运行效果如下

helloworld的回应.jpg

使用工具

众所周知,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)))

运行一下试试——哎呀,出状况了

工具声明有误.jpg

看起来是因为工具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))))

这下工具传递正确、使用成功了

工具使用成功.jpg

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))))))))))

效果如下图所示

多轮对话.jpg

加载配置文件

为了不需要额外的 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 而已。

制作独立可执行文件.jpg

结语

本文出现的所有代码均采用古法编程诚心手敲,童叟无欺。

古法编程写lisp.png

全文完。