从零构建AI剪贴板增强服务:FastAPI与OpenAI集成实践

发布时间:2026/8/28 12:38:35
从零构建AI剪贴板增强服务:FastAPI与OpenAI集成实践 如果给本地开发工具取一个叫 paperclip 的名字最容易联想到的场景就是剪贴板。日常写文档、做表格、回复消息时我们经常要把一段文本从浏览器复制到编辑器去掉多余空行、统一中文标点、翻译成另一种语言然后再复制回去。这个过程往往要切换三四个工具而且完全没有历史记录做完一次就丢了。paperclip 这个示例项目要解决的就是这条链路用一个常驻本地服务统一接收剪贴板文本按规则动作或大模型指令处理再把结果写回剪贴板同时把处理历史持久化到 SQLite。这篇文章以 paperclip 为项目代号从零搭建一个 AI 剪贴板增强服务。它不是前端页面也不是大型分布式系统而是一个适合扔在个人开发机上跑的小工具。读完这篇文章你能掌握 FastAPI 服务如何设计、OpenAI 兼容接口如何封装、处理规则如何和大模型调用结合、SQLite 如何做本地历史记录以及这套东西从开发环境进入生产环境时还要补什么。1. 先理解 paperclip 要解决什么问题再决定怎么实现1.1 剪贴板操作是典型的低价值重复劳动剪贴板本身只是一个系统级缓冲区它负责把一份数据从一个应用搬移到另一个应用。但业务上需要处理的往往是“带格式的文本”。复制一段网页内容粘到 Markdown 文件里可能带过来大量无用的空行和空格复制一段英文邮件要变成中文才能继续处理复制一段会议纪要要提炼成待办事项。这些都是机械操作。如果靠人工逐次整理效率低且容易出错。如果写一个小程序用正则表达式硬编码整理规则又只能覆盖某一种场景换一类文本就失效。paperclip 的定位是把“剪贴板 规则 大模型”组合起来形成一个可扩展的本地文本处理服务。1.2 技术方案选型要先考虑生态和替换成本paperclip 不需要复杂的微服务架构。核心要求是服务能常驻运行等待 CLI 或外部调用。请求格式统一校验简单。大模型调用要能兼容 OpenAI API也能切换到本地模型。历史记录本地保存重启不丢失。基于这些要求技术栈可以这样选模块选型理由Web 框架FastAPI自带请求校验和 OpenAPI 文档异步支持良好LLM 客户端openai Python SDK社区标准接口能对接本地模型和云厂商配置加载pydantic-settings支持环境变量和.env文件配置外置简单剪贴板交互pyperclip跨平台Windows/Linux/macOS 都能用历史存储SQLite零服务部署单文件存储适合个人工具命令行argparse httpx轻量不引入过多依赖这个选型并不追求“最新最热”而是追求稳定和可替换。如果后端从 OpenAI 换成本地 Ollama只需要改环境变量不需要改处理逻辑。如果以后要集成更多模型也可以只扩展processors这一层。1.3 一条文本在 paperclip 里的完整流转链路把整个流程拆开看一条剪贴板文本会经过以下环节[系统剪贴板] - [paperclip CLI] - HTTP POST /api/v1/process - [FastAPI] | [process_text(action)] | -------------------------- | 轻量规则处理 | LLM 大模型处理 | -------------------------- | [SQLite history] | [返回 result] | [CLI 写回剪贴板]CLI 负责读取剪贴板服务端负责处理处理完的文本再通过 CLI 写回剪贴板。这样设计的好处是同一套 API 可以同时给 CLI、快捷指令、浏览器扩展或手机端使用服务本身的职责保持单一。1.4 学习环境与生产环境从一开始就要区分在个人电脑上跑通一个本地服务并不难但如果想让它在团队内部或服务器上稳定运行还需要考虑权限、日志、密钥安全和并发写库。paperclip 的设计里学习环境可以简化掉很多环节比如直接使用 SQLite 和明文配置但生产环境必须单独处理。后续章节会逐步展开这些差异。注意不要只验证程序能启动还要验证输入、输出、异常分支和日志是否符合预期。剪贴板工具面向的是高频小操作任何一个环节处理错了用户都会立即发现。2. 环境准备与项目初始化2.1 环境要求并不高但 Python 版本要统一示例以 Python 3.10 起步建议使用 3.11 或更高版本。环境会用到协程、类型注解和pydantic-settings版本太低会导致依赖解析失败。最小环境要求项目要求操作系统Windows 10 / macOS 12 / LinuxPython3.10 及以上包管理工具pip 或 uv可选依赖Linux 下需要 xclip 或 xsel用于剪贴板访问可选模型服务本地 Ollama或任何 OpenAI 兼容 API 服务如果选择本地模型需要提前确认机器内存和显存。以 7B 参数模型为例纯 CPU 推理速度一般建议至少 16GB 内存有 GPU 时可使用量化版本。2.2 先创建项目目录和虚拟环境项目结构设计如下paperclip/ ├── pyproject.toml ├── .env.example ├── paperclip/ │ ├── __init__.py │ ├── config.py │ ├── llm.py │ ├── processors.py │ ├── history.py │ ├── api.py │ └── cli.py └── tests/ └── test_processors.py命令行操作mkdir -p paperclip/paperclip paperclip/tests cd paperclip python -m venv .venv source .venv/bin/activate # Windows 使用 .venv\Scripts\activate pip install -U pip创建虚拟环境的目的是避免项目依赖污染系统 Python。pyperclip、openai、fastapi这些库会有各自的间接依赖如果在全局环境安装版本冲突很难排查。2.3 用 pyproject.toml 声明依赖pyproject.toml是当前 Python 项目规范中的主配置入口它同时承担依赖声明、项目元数据和命令行入口配置。[project] name paperclip version 0.1.0 description Local AI clipboard assistant requires-python 3.10 dependencies [ fastapi0.110,1.0, uvicorn[standard]0.29,1.0, openai1.30,2.0, pydantic-settings2.0,3.0, pyperclip1.8,2.0, httpx0.27,1.0, ] [project.scripts] paperclip paperclip.cli:main [build-system] requires [hatchling] build-backend hatchling.build这里有两个关键点。第一openaiSDK 本身已经依赖httpx但 CLI 端也会直接使用httpx所以显式声明它是合理做法。第二[project.scripts]让安装后的项目可以直接在命令行执行paperclip不需要去记python -m paperclip.cli。对工具类项目来说体验差异很明显。2.4 配置项要外置不能把 API Key 写进代码项目使用环境变量前缀PAPERCLIP_来隔离配置。这样.env、系统环境变量、容器环境变量可以无缝切换。.env.example示例PAPERCLIP_HOST127.0.0.1 PAPERCLIP_PORT8000 PAPERCLIP_LLM_BASE_URLhttps://api.openai.com/v1 PAPERCLIP_LLM_API_KEYsk-xxx PAPERCLIP_LLM_MODELgpt-4o-mini PAPERCLIP_LLM_TIMEOUT30 PAPERCLIP_DB_PATHpaperclip.db各配置项含义配置项含义默认值注意事项PAPERCLIP_HOSTAPI 监听地址127.0.0.1生产环境改成 0.0.0.0 前要确认访问控制PAPERCLIP_PORTAPI 监听端口8000端口冲突时可换成其他端口PAPERCLIP_LLM_BASE_URLOpenAI 兼容接口地址官方地址本地模型改成 http://127.0.0.1:11434/v1PAPERCLIP_LLM_API_KEY模型服务密钥空本地模型通常可设置为 EMPTYPAPERCLIP_LLM_MODEL模型名gpt-4o-mini必须与服务端已部署模型一致PAPERCLIP_LLM_TIMEOUT模型请求超时时间30超时过短长文本容易失败PAPERCLIP_DB_PATH历史记录数据库路径paperclip.db生产环境建议放到有备份的磁盘注意.env不要提交到 Git 仓库。建议把.env.example提交把.env加入.gitignore避免密钥泄露。3. 核心模块实现3.1 配置加载用 pydantic-settings 统一管理环境变量config.py是整个项目的配置入口。它读取环境变量并提供带缓存的get_settings函数避免多次加载重复解析。from functools import lru_cache from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): host: str 127.0.0.1 port: int 8000 llm_base_url: str https://api.openai.com/v1 llm_api_key: str llm_model: str gpt-4o-mini llm_timeout: float 30.0 db_path: str paperclip.db model_config SettingsConfigDict( env_prefixPAPERCLIP_, env_file.env, env_file_encodingutf-8, ) lru_cache def get_settings() - Settings: return Settings()env_prefix的作用是自动把PAPERCLIP_HOST映射到host。如果没有这一层前缀所有环境变量名会变得非常通用容易和系统其他变量冲突。3.2 LLM 客户端封装对外只暴露一个 chat 函数在llm.py中封装对模型的调用。虽然底层是 OpenAI SDK但业务层不应该感知client.chat.completions.create的细节。后续如果切换 SDK只需要改这个文件。from openai import OpenAI from .config import Settings def _build_client(settings: Settings) - OpenAI: api_key settings.llm_api_key or EMPTY return OpenAI( base_urlsettings.llm_base_url, api_keyapi_key, timeoutsettings.llm_timeout, ) def chat(settings: Settings, system_prompt: str, user_prompt: str) - str: client _build_client(settings) response client.chat.completions.create( modelsettings.llm_model, messages[ {role: system, content: system_prompt}, {role: user, content: user_prompt}, ], temperature0.2, ) return response.choices[0].message.content or temperature设置为 0.2是为了让格式整理、翻译、总结这类任务尽量稳定。剪贴板处理场景不是创意写作不需要太高的随机性。如果模型返回空字符串or 保证返回值是字符串类型后续处理不会因为None报错。3.3 处理链先规则后模型把成本降下来不是所有文本都需要调用大模型。短文本去空格、去空行用正则表达式就能做而且比模型快得多。processors.py实现一个简单的处理链按动作分发优先走规则必要时才调 LLM。import re from .config import Settings from .llm import chat class PaperclipProcessError(Exception): pass SUPPORTED_ACTIONS {format, translate, summarize, reply} PROMPTS { translate: ( 你是一名专业翻译把用户给出的内容翻译成简体中文只输出译文不要解释。, 请翻译\n{}, ), summarize: ( 你是一名会议纪要整理助手用简洁的中文输出不超过 200 字的摘要不要输出额外内容。, 请总结\n{}, ), reply: ( 你是一名耐心的工作助手根据用户提供的信息生成一段得体的中文回复草稿。, 请生成回复\n{}, ), } def format_fast(text: str) - str: lines [line.strip() for line in text.splitlines()] cleaned_lines [line for line in lines if line] text \n.join(cleaned_lines) text re.sub(r[ \t], , text) return text def process_text(text: str, action: str, settings: Settings) - str: if not text.strip(): raise PaperclipProcessError(text is empty) action action.lower() if action not in SUPPORTED_ACTIONS: raise PaperclipProcessError(funsupported action: {action}) if action format: if len(text) 200: return format_fast(text) system_prompt ( 你是文本整理助手清理多余空行和空格统一中英文标点 保留原有含义只输出处理后的结果。 ) return chat(settings, system_prompt, text) system_prompt, template PROMPTS[action] return chat(settings, system_prompt, template.format(text))这个设计的核心思路是“分级处理”简单格式整理规则优先速度快零成本。长文本格式整理规则可能破坏语义必须交给 LLM。翻译、总结、回复本身依赖语义理解必须使用 LLM。每次传入的action都要严格校验避免用户直接拼一个不在白名单里的动作。3.4 历史记录SQLite 表设计与连接管理history.py负责数据库初始化、写入和查询。个人工具规模下SQLite 单文件存储足够不需要额外安装数据库服务。import sqlite3 import time def init_db(db_path: str) - None: with sqlite3.connect(db_path) as conn: conn.executescript( PRAGMA journal_modeWAL; CREATE TABLE IF NOT EXISTS history ( id INTEGER PRIMARY KEY AUTOINCREMENT, created_at REAL NOT NULL, action TEXT NOT NULL, source_text TEXT NOT NULL, result_text TEXT NOT NULL ); ) def insert_history(conn: sqlite3.Connection, action: str, source_text: str, result_text: str) - None: conn.execute( INSERT INTO history (created_at, action, source_text, result_text) VALUES (?, ?, ?, ?) , (time.time(), action, source_text, result_text), ) conn.commit() def list_history(conn: sqlite3.Connection, limit: int 20) - list[dict]: rows conn.execute( SELECT id, created_at, action, source_text, result_text FROM history ORDER BY id DESC LIMIT ? , (limit,), ).fetchall() return [dict(row) for row in rows]PRAGMA journal_modeWAL是 SQLite 的写前日志模式。它能在多线程或进程同时读写时降低锁冲突概率。学习环境不打开也能跑但既然代码量只有一行加上总是值得的。3.5 API 层最小但完整的 FastAPI 服务api.py把配置、处理链和数据库连接组合起来对外暴露两个核心接口处理文本、查询历史。from contextlib import asynccontextmanager import sqlite3 from fastapi import Depends, FastAPI, HTTPException from pydantic import BaseModel, Field from .config import get_settings from .history import init_db, insert_history, list_history from .processors import PaperclipProcessError, process_text asynccontextmanager async def lifespan(app: FastAPI): init_db(get_settings().db_path) yield app FastAPI(titlepaperclip, version0.1.0, lifespanlifespan) class ProcessRequest(BaseModel): text: str Field(..., min_length1, max_length20000) action: str Field(format, max_length20) class ProcessResponse(BaseModel): action: str source: str result: str def get_conn(): conn sqlite3.connect(get_settings().db_path) conn.row_factory sqlite3.Row try: yield conn finally: conn.close() app.post(/api/v1/process, response_modelProcessResponse) def process(req: ProcessRequest, conn: sqlite3.Connection Depends(get_conn)): try: result process_text(req.text, req.action, get_settings()) except PaperclipProcessError as exc: raise HTTPException(status_code400, detailstr(exc)) except Exception as exc: raise HTTPException(status_code502, detailfLLM processing failed: {exc}) insert_history(conn, req.action, req.text, result) return ProcessResponse(actionreq.action, sourcereq.text, resultresult) app.get(/api/v1/history) def history(limit: int 20, conn: sqlite3.Connection Depends(get_conn)): return {items: list_history(conn, limit)}这里的异常处理策略很明确输入的action不支持、文本为空属于客户端问题返回 400。模型不可用、超时、返回异常是服务端依赖问题返回 502。业务层不直接暴露底层异常详情避免把模型服务的密钥或内部路径带出去。3.6 CLI 客户端读取剪贴板并写回剪贴板cli.py负责命令行入口。它做三件事从剪贴板读取文本把文本发到 API把结果写回剪贴板。import argparse import httpx import pyperclip from .config import get_settings def main() - int: parser argparse.ArgumentParser(descriptionpaperclip CLI) parser.add_argument(--text, help直接传入文本默认从剪贴板读取) parser.add_argument(--action, defaultformat, helpformat/translate/summarize/reply) parser.add_argument(--no-write-back, actionstore_true, help处理结果不写回剪贴板) parser.add_argument(--server, defaultNone, help服务地址默认读环境变量) args parser.parse_args() settings get_settings() server args.server or fhttp://{settings.host}:{settings.port} text args.text or pyperclip.paste() if not text.strip(): print(clipboard is empty) return 1 response httpx.post( f{server}/api/v1/process, json{text: text, action: args.action}, timeout60.0, ) response.raise_for_status() data response.json() result data[result] print(result) if not args.no_write_back: pyperclip.copy(result) return 0 if __name__ __main__: raise SystemExit(main())CLI 使用--no-write-back参数来控制是否写回剪贴板。默认写回是因为频繁粘贴场景通常需要连续处理多段文本当只是预览结果时可以关掉写回避免覆盖剪贴板里的原始内容。4. 启动、测试与验证4.1 安装依赖并启动服务项目根目录执行安装命令pip install -e .[dev]如果 pyproject 中尚未声明 dev 依赖也可以只执行pip install -e .然后启动 FastAPI 服务uvicorn paperclip.api:app --host 127.0.0.1 --port 8000启动后浏览器访问http://127.0.0.1:8000/docs可以看到 FastAPI 自动生成的接口文档。这是一个很好的检查点服务能启动接口文档能打开说明依赖和导入关系基本正常。4.2 用 curl 跑通一个最小请求开启另外一个终端执行curl -X POST http://127.0.0.1:8000/api/v1/process \ -H Content-Type: application/json \ -d {text:hello world\n\n\nsecond line,action:format}正常响应类似于{ action: format, source: hello world\n\n\nsecond line, result: hello world\nsecond line }这个请求走的是规则路径不需要模型参与因此即使PAPERCLIP_LLM_API_KEY为空也能跑通。4.3 用 CLI 完成一次剪贴板闭环先复制一段测试文本到剪贴板然后执行paperclip --action format如果局域网内另一台机器调用可以指定服务地址paperclip --server http://192.168.1.10:8000 --action translate命令行输出就是处理后的结果。默认情况下这个结果会被写回剪贴板直接粘贴即可使用。4.4 用 pytest 做最小回归新建tests/test_processors.pyfrom paperclip.processors import format_fast def test_format_fast_removes_empty_lines_and_extra_spaces(): source hello world\n\n\nsecond line assert format_fast(source) hello world\nsecond line执行测试pytest -q这里至少验证了一个边界多个连续空行、行内多个空格能否被正确清理。后续每增加一个新的动作都应该至少配套一个测试避免重构时破坏已有行为。4.5 响应字段说明/api/v1/process返回结构设计得很简单字段类型说明actionstring实际执行的动作sourcestring传入的原始文本resultstring处理后的文本source字段看起来冗余但它在调试阶段价值很大。收到错误结果时可以确认服务端收到的原始文本和 CLI 发送时是否一致。如果客户端在传输前做了编码转换source能直接暴露问题。5. 常见问题与排查路径5.1 Linux 下剪贴板读取为空或直接报错现象在 Linux 桌面环境执行paperclip提示剪贴板为空或抛出PyperclipException。可能原因pyperclip 在 Linux 下依赖xclip或xsel没有安装这些工具就无法访问 X 系统剪贴板。检查方式which xclip which xsel处理建议sudo apt install xclip或者把pyperclip的依赖切换为wl-clipboard对应 Wayland 环境。这属于环境依赖问题不是程序逻辑问题。5.2 LLM 请求超时或返回 401/404现象执行translate、summarize、reply动作时接口返回 502服务端日志中能看到 openai SDK 报错。可能原因PAPERCLIP_LLM_BASE_URL配错。PAPERCLIP_LLM_API_KEY为空或已失效。PAPERCLIP_LLM_MODEL与模型服务端部署的模型名不一致。网络不通服务端无法访问模型 API。检查方式curl http://127.0.0.1:11434/v1/models如果是 OpenAI 官方接口检查请求能否携带密钥正常访问。如果使用本地模型确认模型名ollama list处理建议先手动请求模型服务的/v1/models确认地址、模型名和密钥都正确再回到 paperclip 重试。5.3 服务启动时端口被占用现象执行 uvicorn 启动命令后终端提示address already in use。可能原因本地已有服务占用了 8000 端口或上一次 paperclip 进程没有退出。检查方式lsof -i :8000处理建议换一个端口例如 8010PAPERCLIP_PORT8010 uvicorn paperclip.api:app --host 127.0.0.1 --port 8010如果端口已经被旧进程占用可以先结束旧进程再启动。5.4 SQLite 数据库锁问题现象同时在多个终端执行请求时偶尔出现database is locked错误。可能原因SQLite 对并发写连接有限制多个连接同时写同一个数据库文件可能触发锁冲突。检查方式查看服务端日志是否出现OperationalError。处理建议在初始化时开启 WAL 模式代码里已经包含PRAGMA journal_modeWAL。如果并发量继续上升就说明工具超出了个人使用场景应该把历史存储切换到 PostgreSQL。另外FastAPI 中每个请求创建一个新的 SQLite 连接用完即关闭避免长连接持有锁。5.5 CLI 调用动作不对现象用paperclip --action format可以工作但paperclip --action translate返回 “unsupported action”。可能原因CLI 的 action 参数大小写不一致或者服务端部署的代码版本较旧还未支持translate。检查方式paperclip --action Translate处理建议在process_text里对 action 做了lower()处理因此传入Translate也能通过。如果仍失败直接查看服务端代码中SUPPORTED_ACTIONS的集合确认是否包含该动作。6. 最佳实践与扩展方向6.1 从个人工具升级到生产服务时要补齐的清单个人的本地工具和生产服务对稳定性的要求完全不同。可以从下面几个维度逐项检查配置安全API Key 不要写在代码里使用环境变量或密钥管理服务。日志每个请求记录动作、耗时、结果长度和错误类型方便回溯。访问控制服务不要裸奔在公网上。监听127.0.0.1是默认安全行为要对局域网开放时考虑加一层 token 鉴权。模型超时长文本处理可能超过 30 秒超时时间要可配置。数据备份SQLite 文件要纳入备份策略历史记录对工具类产品很有价值。回滚当模型接口异常时可以考虑降级到规则处理而不是直接返回 502。容器化是常见的部署方式可以参考以下 DockerfileFROM python:3.11-slim WORKDIR /app COPY . . RUN pip install --no-cache-dir . EXPOSE 8000 CMD [uvicorn, paperclip.api:app, --host, 0.0.0.0, --port, 8000]注意容器内监听0.0.0.0意味着容器内所有网卡都可以访问真正对外暴露时还必须配合 Docker 网络策略或反向代理认证。6.2 规则处理与大模型处理的取舍不是所有场景都必须调用大模型。规则处理速度快、结果稳定、成本为零大模型处理能力强但存在随机性和延迟。paperclip 用“先规则后模型”的思路做了第一层控制。场景建议方式原因短文本去多余空行正则表达式结果确定毫秒级完成长文本统一格式大模型涉及上下文和标点语义规则难覆盖翻译大模型语言语义复杂会议摘要大模型需要提炼关键信息固定格式的错误码整理规则有严格格式约束模型反而可能“画蛇添足”这里的核心原则是能用规则解决的问题不要为了“接入 AI”而强行调用模型。成本只是一方面稳定性更重要。6.3 剪贴板数据安全不容忽视剪贴板往往包含密码、身份证号、聊天记录、内部文档等敏感信息。把剪贴板内容发送到云模型之前要做一道判断。建议措施本地部署模型用于隐私数据例如 Ollama 量化模型。云模型场景下CLI 增加--confirm-upload参数明确告知用户即将把数据发送到外部服务。历史记录数据库中增加字段标记是否涉敏且提供清理接口。不要在数据库中明文保存密钥更不要把完整历史记录暴露给不信任的调用方。6.4 可以扩展的方向paperclip 目前只覆盖文本剪贴板实际可扩展的地方很多系统级快捷键绑定调用 CLI而不是打开终端。浏览器扩展选中文本后直接调用本地 API。图片 OCR截图后识别文字再进入处理链。多模型路由根据动作选择不同模型例如翻译用小模型总结用大模型。插件系统把每个动作写成独立插件注册到SUPPORTED_ACTIONS中。历史搜索对历史记录做全文索引方便找回之前处理过的文本。6.5 最值得记住的三条实践建议第一工具类项目最怕“全链路魔改”。把配置、模型调用、处理逻辑、存储拆成独立模块任何一个环节出错都不会影响其他环节。第二处理结果要能被人快速验证。CLI 默认把结果写回剪贴板用户可以直接粘贴但如果写回错误会覆盖原始内容所以必须提供--no-write-back这样的逃生通道。第三不要把外部依赖写死在代码里。模型地址、模型名、超时时间、监听端口都应该通过配置控制否则换一台机器部署改代码是不可接受的方案。从学习角度回到 paperclip 这个例子建议先跑通format动作再逐步加入translate、summarize最后再研究 Docker 和鉴权。每一层功能都可以独立验证排查问题时也能快速定位到底是在剪贴板、HTTP、处理逻辑还是模型调用这一环出了问题。