ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Common Lisp集成LLM实战:构建AI客户端与REPL智能助手

Common Lisp集成LLM实战:构建AI客户端与REPL智能助手 1. 项目概述当古老的Lisp遇见现代的LLM如果你是一位Common Lisp的开发者看到现在AI领域如火如荼尤其是大语言模型LLM几乎成了所有技术栈的“标配”心里会不会有点痒会不会觉得自己的Lisp世界和外面的AI浪潮之间隔着一道无形的墙我最初就是这么想的。Common Lisp这门拥有数十年历史、以强大表达能力和宏系统著称的语言在数据处理、符号计算、快速原型开发上有着独特的魅力。然而当我想把OpenAI的GPT或者本地部署的Llama模型集成到我的Lisp项目中时却发现社区资源远不如Python、JavaScript那样丰富。难道Lisp开发者就只能旁观吗当然不是。“时代在召唤——Common Lisp调用LLM”这个项目正是为了解决这个痛点。它的核心目标就是为Common Lisp社区搭建一座通向现代LLM服务的桥梁。无论是想在你的Lisp应用中添加一个智能对话功能还是想用Lisp优雅地处理和分析LLM返回的结构化数据甚至是构建基于LLM的Lisp原生AI智能体Agent这个探索都将为你提供一套可行的思路和实用的代码片段。这不仅仅是简单的API封装更是思考如何用Lisp的哲学例如代码即数据、强大的宏、交互式开发来更优雅地驾驭LLM的能力。接下来我将从设计思路、核心实现、到实战踩坑完整地分享如何让Common Lisp顺畅地与LLM对话。2. 核心设计思路与架构选型在开始敲代码之前我们需要明确几个关键的设计决策。这些决策决定了后续实现的复杂度、灵活性和性能。2.1 协议与传输层HTTP Client的选择LLM服务提供商如OpenAI、Anthropic、以及各类开源模型的本地API绝大多数都提供基于HTTP的RESTful API。因此在Common Lisp中调用LLM首要任务就是选择一个稳定、易用的HTTP客户端库。经过对比我主要推荐两个选择Dexador这是目前Common Lisp社区最活跃、功能最全面的HTTP客户端之一。它的API设计友好支持HTTPS、异步请求、连接池等现代特性并且对JSON的处理有很好的支持通常结合jonathan或cl-json库。对于大多数项目Dexador是首选。Drakma一个更老牌、更轻量级的HTTP客户端。它在简单场景下非常可靠但功能上不如Dexador丰富。如果你的项目依赖已经包含了Drakma或者需求极其简单它也是一个可选项。注意选择Dexador时请确保你的Quicklisp发行版足够新或者直接从其Git仓库安装以获得最好的兼容性。为什么选择Dexador除了功能全面更重要的是它的错误处理和超时机制更完善。调用远程API网络不稳定、服务端响应慢是常态一个健壮的客户端必须能妥善处理这些情况。Dexador提供了:timeout等参数能让我们更好地控制请求行为。2.2 数据序列化与JSON共舞LLM API的请求体和响应体基本都是JSON格式。Common Lisp处理JSON主要有两种范式将JSON映射到Lisp对象反序列化使用如cl-json或jonathan库将JSON字符串转换成Lisp的列表list、哈希表hash-table等结构方便后续处理。将Lisp对象编码为JSON序列化将我们构建好的Lisp请求参数同样是列表或哈希表编码成JSON字符串发送给API。jonathan库因其高性能和简洁的API近年来备受青睐。它允许你使用Lisp的list和alist关联列表来自然地表示JSON对象编码解码几乎是无感的。设计考量我们不应该在业务代码中到处散落HTTP请求和JSON解析的细节。一个好的设计是抽象出一个协议层。这一层负责构建符合特定LLM API要求的HTTP请求头如Authorization: Bearer api-key。将Lisp数据结构的请求参数序列化为JSON。发送HTTP请求并处理状态码例如遇到429速率限制错误时自动重试或报错。将响应的JSON正文反序列化为Lisp数据结构或者直接提取出我们关心的文本内容。这样上层的业务代码只需要关心“我想问模型什么问题”而不必理会底层的网络和格式细节。2.3 模型抽象与多提供商支持不同的LLM提供商其API端点、参数名称、响应格式都有差异。例如OpenAI的Chat Completion接口参数叫messages而Anthropic的Claude接口参数可能叫prompt。我们不能把提供商特定的逻辑硬编码得到处都是。因此引入一个模型抽象层是至关重要的。我们可以定义一个通用的LLM-MODEL类或结构体包含如name,provider,api-base-url,api-key等属性。然后为每个支持的提供商如OpenAI、Anthropic、Ollama实现一个具体的后端适配器。这个适配器的职责是知道如何将该提供商的通用请求参数如消息列表、模型名、温度转换为其特定API所需的格式。知道如何解析该提供商返回的响应并提取出标准化的“回复内容”和“使用量”tokens信息。通过这种设计我们可以实现一个统一的函数比如(generate-chat-completion model messages :temperature 0.7)而函数内部会根据model的具体类型分派到对应的适配器去执行。这为未来支持更多LLM服务打下了坚实的基础。3. 核心实现构建Common Lisp的LLM客户端理论说得再多不如一行代码。让我们从最简单的开始逐步构建一个可用的模块。3.1 基础环境搭建与依赖管理首先确保你有一个可用的Common Lisp环境如SBCL、CCL和Quicklisp。然后在你的项目.asd文件或REPL中加载必要的库。;; 在你的 .asd 文件里 :depends-on (#:dexador #:jonathan #:babel #:quri) ;; 或者在 REPL 中快速加载 (ql:quickload (:dexador :jonathan :babel :quri))dexador: HTTP客户端。jonathan: JSON编码/解码。babel: 字符编码转换有时处理非ASCII文本需要。quri: URI构造与处理库用于优雅地构建请求URL。3.2 实现一个最简化的OpenAI API调用我们先抛开复杂的抽象实现一个直接调用OpenAI Chat API的函数。这能让我们立刻看到效果理解整个流程。(defparameter *openai-api-key* (uiop:getenv \OPENAI_API_KEY\)) (defparameter *openai-api-url* \https://api.openai.com/v1/chat/completions\) (defun call-openai-simple (prompt key (model \gpt-3.5-turbo\) (temperature 0.7)) \一个最简单的OpenAI调用函数。\ (unless *openai-api-key* (error \请设置环境变量 OPENAI_API_KEY\)) (let* ((headers ((\Authorization\ . ,(format nil \Bearer ~a\ *openai-api-key*)) (\Content-Type\ . \application/json\))) (data (jonathan:to-json (:model ,model :messages ((:role \user\ :content ,prompt)) :temperature ,temperature))) (response (dex:post *openai-api-url* :headers headers :content data :want-stream nil))) ; 注意want-stream参数 ;; dexador默认返回的是 (values body status headers uri stream) ;; 我们通常只关心body (let ((response-body (jonathan:parse (first response)))) ;; 从复杂的响应结构中提取出助理的回复内容 (getf (getf (first (getf response-body :choices)) :message) :content))))代码解析与实操要点API密钥安全永远不要将API密钥硬编码在代码中。这里通过uiop:getenv从环境变量读取这是最佳实践。你也可以使用类似cl-dotenv的库来管理。JSON构建我们使用反引号backtick和逗号comma构建了一个Lisp列表其结构完全对应OpenAI API所需的JSON。jonathan:to-json会将其转换为字符串。注意messages是一个列表里面包含一个角色为\user\的消息。Dexador调用dex:post函数发起POST请求。关键参数:want-stream nil表示我们不需要流式响应等待完整响应返回。对于简单的文本补全这足够了。如果你需要处理流式输出一个字一个字地返回则需要将其设为t并处理返回的流stream。响应解析dex:post返回多个值第一个是响应体字符串。我们用jonathan:parse将其解析为Lisp的property listplist。OpenAI的响应结构是{:choices [{:message {:content \...\}}]}所以我们用一系列getf来钻取数据。错误处理这个简单版本没有错误处理。如果API返回错误如401认证失败、429限速dex:post会抛出dex:http-request-failed条件。在生产代码中你必须用handler-case或handler-bind来捕获并处理它。在REPL里测试一下CL-USER (call-openai-simple \用Common Lisp写一个Hello World函数\) \当然以下是一个简单的 Common Lisp Hello World 函数定义\\n\\nlisp\\n(defun hello-world () \\n (format t \\\Hello, World!~%\\\))\\n\\n\\n你可以调用 (hello-world) 来执行它它会在标准输出打印 \Hello, World!\。\看你的Common Lisp环境已经能和GPT对话了3.3 构建健壮且通用的客户端模块简单版本能用但远远不够健壮和通用。接下来我们构建一个更完善的模块。3.3.1 定义核心协议与模型抽象(defpackage :cl-llm (:use :cl) (:export #:llm-model #:make-openai-model #:generate-chat-completion #:with-llm-model)) (in-package :cl-llm) ;;; 定义一个通用的LLM模型类 (defclass llm-model () ((name :initarg :name :reader model-name) (provider :initarg :provider :reader model-provider) ; :openai, :anthropic, :ollama等 (api-key :initarg :api-key :reader model-api-key) (base-url :initarg :base-url :reader model-base-url) (default-parameters :initarg :default-parameters :initform () :reader model-default-parameters)) (:documentation \表示一个LLM模型的通用类。\)) ;;; 具体OpenAI模型的构造函数 (defun make-openai-model (key (name \gpt-3.5-turbo\) (api-key (uiop:getenv \OPENAI_API_KEY\))) (make-instance llm-model :name name :provider :openai :api-key api-key :base-url \https://api.openai.com/v1\ :default-parameters (:temperature 0.7 :max-tokens 500)))3.3.2 实现OpenAI适配器与统一接口现在我们实现一个分派机制和OpenAI适配器。;;; 通用的消息结构用于构建对话历史 (defstruct (chat-message (:conc-name msg-)) role ; \system\, \user\, \assistant\ content) ;;; 主函数生成聊天补全 (defgeneric generate-chat-completion (model messages key allow-other-keys) (:documentation \根据给定的模型和消息历史生成回复。\)) ;;; OpenAI适配器的具体实现 (defmethod generate-chat-completion ((model llm-model) messages key (temperature nil) (max-tokens nil) (stream nil)) (unless (eql (model-provider model) :openai) (error \Provider ~a not supported by this method.\ (model-provider model))) (let ((api-key (model-api-key model)) (base-url (model-base-url model))) (unless api-key (error \API key for model ~a is not set.\ (model-name model))) (let* ((url (format nil \~a/chat/completions\ base-url)) (headers ((\Authorization\ . ,(format nil \Bearer ~a\ api-key)) (\Content-Type\ . \application/json\))) ;; 合并默认参数和传入的参数 (params (append (model-default-parameters model) (when temperature (:temperature ,temperature)) (when max-tokens (:max-tokens ,max-tokens)) (:model ,(model-name model) :messages ,(mapcar (lambda (msg) (:role ,(msg-role msg) :content ,(msg-content msg))) messages) :stream ,stream))) (json-data (jonathan:to-json params))) (multiple-value-bind (body status-code) (dex:post url :headers headers :content json-data :want-stream stream) ; 注意流式处理 (if stream ;; 流式响应处理高级主题此处简化 (progn (format t \~%[Streaming response...]~%\) ;; 这里需要循环读取流解析Server-Sent Events (SSE) ;; 为简化我们先不实现完整流式仅提示 (error \Streaming response handling not fully implemented in this example.\)) ;; 非流式响应处理 (if ( status-code 200) (let ((response-json (jonathan:parse body))) ;; 提取回复内容和使用量 (values (getf (getf (first (getf response-json :choices)) :message) :content) (getf response-json :usage))) ;; 处理错误 (error \API request failed with status ~a: ~a\ status-code body)))))))关键改进与经验参数合并函数优先使用调用时传入的temperature等参数如果未传入则使用模型对象中存储的default-parameters。这提供了灵活性。结构化消息我们定义了chat-message结构体使构建多轮对话历史更清晰。例如(list (make-chat-message :role \system\ :content \你是一个Common Lisp专家。\) (make-chat-message :role \user\ :content \如何定义宏\))流式响应占位代码中预留了流式响应stream t的处理分支。OpenAI的流式响应使用Server-Sent Events (SSE)格式处理起来更复杂需要逐块读取和解析。对于初版可以先实现非流式。返回多个值函数使用values返回了回复内容和使用量token数。这在计费和调试时非常有用。3.3.3 添加错误处理与重试机制网络请求必须考虑失败。我们可以定义一个宏或包装函数来添加重试逻辑。(defun call-api-with-retry (api-call-fn key (max-retries 3) (retry-delay 1)) \包装API调用函数在遇到可重试错误时自动重试。\ (loop for retry-count from 0 to max-retries do (handler-case (return-from call-api-with-retry (funcall api-call-fn)) (dex:http-request-failed (c) (let ((status (dex:response-status c))) (cond (( status 429) ; 速率限制 (format *error-output* \~%Rate limited. Retrying after ~a seconds...\ retry-delay) (sleep retry-delay) (incf retry-delay 2)) ; 退避策略延迟递增 (( status 500) ; 服务器错误 (if ( retry-count max-retries) (progn (format *error-output* \~%Server error ~a. Retry ~a/~a...\ status (1 retry-count) max-retries) (sleep retry-delay)) (error \Server error ~a after ~a retries.\ status max-retries))) (t ; 客户端错误如401, 404不应重试 (error c))))) (error (c) ; 其他错误如网络超时 (if ( retry-count max-retries) (progn (format *error-output* \~%Network error: ~a. Retry ~a/~a...\ c (1 retry-count) max-retries) (sleep retry-delay)) (error \Failed after ~a retries: ~a\ max-retries c))))) finally (error \Max retries (~a) exceeded.\ max-retries))) ;;; 修改 generate-chat-completion 方法用 call-api-with-retry 包装核心请求 (defmethod generate-chat-completion :around ((model llm-model) messages key allow-other-keys) (declare (ignore messages)) (call-api-with-retry (lambda () (call-next-method)) ; 调用主方法 :max-retries 3 :retry-delay 2))实操心得错误处理是生产级代码的基石。对于429速率限制和5xx服务器内部错误这类暂时性错误采用指数退避策略进行重试是标准做法。这里我们用了简单的递增延迟。更复杂的系统可能会使用更精细的退避算法。对于401未授权、404未找到这类客户端错误重试是没用的应该立即失败并给出明确错误信息。4. 进阶应用与场景探索有了基础的客户端我们就可以探索一些更有趣的应用场景展示Lisp与LLM结合的魅力。4.1 场景一交互式REPL助手Common Lisp的REPL读取-求值-打印循环是其灵魂。我们可以构建一个REPL助手让你在编码时随时向LLM提问。(defun repl-ai-helper (key (model (make-openai-model))) \启动一个简单的REPL将输入的问题发送给LLM并打印回答。\ (format t \~% Common Lisp REPL AI Helper ~%\) (format t \Type your question (or quit to exit).~%~%\) (loop for query (progn (format t \ \) (read-line)) until (string-equal query \quit\) do (handler-case (let ((response (generate-chat-completion model (list (make-chat-message :role \user\ :content query))))) (format t \~%~a~%~%\ response)) (error (c) (format t \[ERROR] ~a~%~%\ c)))))这个简单的循环让你可以在REPL里直接和GPT对话询问Lisp语法、算法实现甚至让它帮你解释错误信息。4.2 场景二代码分析与文档生成利用LLM强大的代码理解能力我们可以编写一个函数让它分析一个Lisp文件中的函数并自动生成文档字符串。(defun generate-docstring-for-function (function-name model) \为指定的函数名符号生成文档字符串。\ (let* ((source (or (ignore-errors (function-lambda-expression function-name)) (format nil \(defun ~a ...)\ function-name))) ; 简化处理 (prompt (format nil \你是一个Common Lisp专家。请为以下函数定义编写一个清晰的、符合Common Lisp风格的文档字符串。文档字符串应描述函数的功能、参数和返回值。~%~%函数定义~a~%~%请只输出文档字符串内容不要有其他解释。\ source))) (generate-chat-completion model (list (make-chat-message :role \user\ :content prompt)) :temperature 0.3))) ; 降低温度使输出更确定、更规范思路解析这个函数首先尝试获取函数的源码这依赖于具体实现function-lambda-expression并非总是可用生产环境需要更稳健的方法如从源代码文件读取。然后它构造一个非常具体的提示prompt要求LLM扮演Lisp专家并只输出文档字符串。通过设置较低的temperature如0.3我们可以得到更稳定、格式更统一的输出。4.3 场景三结构化数据提取与RAG雏形LLM在从非结构化文本中提取结构化信息方面非常出色。假设我们有一堆Lisp相关的邮件列表或论坛帖子文本我们可以让LLM帮我们提取出“问题描述”和“解决方案”。(defun extract-qa-from-text (text model) \从一段文本中提取出可能的问题Q和答案A对。\ (let ((prompt (format nil \请分析以下Common Lisp相关的文本识别出其中提出的问题Q和给出的解决方案或答案A。请以JSON格式输出一个列表每个元素是一个对象包含question和answer两个字段。如果某部分只是讨论没有明确问答请忽略。~%~%文本~a~%~%只输出JSON不要有其他内容。\ text))) (let ((response (generate-chat-completion model (list (make-chat-message :role \user\ :content prompt)) :temperature 0.1))) ; 极低温度确保JSON格式严格 ;; 这里假设LLM返回的是合法的JSON字符串 (jonathan:parse response))))这其实就是简化版的RAG检索增强生成中的“提取”步骤。你可以将提取出的QA对存入数据库后续就可以构建一个简单的知识库问答系统。Lisp强大的符号处理和数据结构能力使得处理这种转换后的数据非常自然。5. 常见问题、调试技巧与性能优化在实际集成过程中你肯定会遇到各种问题。下面是我踩过的一些坑和总结的经验。5.1 网络与认证问题问题dex:http-request-failed错误状态码 401 (Unauthorized)。排查检查API密钥确保环境变量OPENAI_API_KEY已设置且正确。在REPL里执行(uiop:getenv \OPENAI_API_KEY\)看看是否返回预期值。检查密钥格式OpenAI的密钥通常以sk-开头。确保没有多余的空格或换行符。检查请求头确认Authorization头的格式是Bearer your-api-key。技巧可以写一个简单的测试函数只发送一个最小的请求比如用dex:get调用一个不需要认证的端点先确保网络连通性和Dexador配置正确。5.2 JSON解析错误问题jonathan:parse失败提示JSON格式无效。排查打印原始响应在调用jonathan:parse之前先用(format t \~a\ body)打印出响应的原始字符串。很可能API返回的不是JSON而是一个HTML错误页面比如Nginx的502错误。检查编码确保响应体是UTF-8编码。虽然现代API基本都是但以防万一。Dexador通常会处理好。流式响应误判如果你意外地将:want-stream设为t但试图解析返回的流stream对象为JSON就会失败。流需要按SSE格式读取。技巧使用handler-case包裹JSON解析代码在出错时打印出有问题的字符串片段便于诊断。5.3 流式响应处理处理OpenAI的流式响应stream: true是另一个挑战。它返回的是text/event-stream格式的数据不是单个JSON。(defun call-openai-streaming (prompt model) (let* ((url \https://api.openai.com/v1/chat/completions\) (headers ...) ; 同上 (data (jonathan:to-json (:model ,(model-name model) :messages ((:role \user\ :content ,prompt)) :stream t))) (stream (dex:post url :headers headers :content data :want-stream t))) (unwind-protect (loop for line (read-line stream nil nil) while line do (when (and ( (length line) 6) (string \data: \ line :end2 6)) (let ((data-str (subseq line 6)))) (unless (string>
返回列表