1. 项目概述:Paperclip 不是回形针,而是一个轻量级 AI Agent 框架
你搜“paperclip”时,第一反应可能是办公桌上那个银色小金属片——但在这波 AI 工具浪潮里,Paperclip 是一个真实存在的、开源的、基于 Node.js + React 构建的 AI Agent 开发框架。它不是大厂出品的庞然大物,也不是学术论文里的概念模型,而是一个由开发者社区驱动、专为“快速验证 Agent 思路”而生的脚手架。我第一次在 GitHub 上看到它时,仓库 star 数刚过 300,README 里只有一行核心描述:“A minimal, extensible framework for building AI agents with TypeScript, Node.js, and React.” —— 就是这句“minimal & extensible”,让我决定把它拆开揉碎,跑通第一个本地 demo。
它解决的不是“如何训练大模型”这种底层问题,而是更实际的痛点:当你脑子里冒出一个 Agent 点子(比如“自动整理会议纪要并生成待办清单”“根据 Slack 消息触发 Jira 创建任务”),你不想从零搭 Express 路由、写 WebSocket 通信、手搓 React 状态管理、再硬塞进 LLM API 调用逻辑——Paperclip 就是帮你把这层胶水代码全抽出来,让你专注在“Agent 的行为逻辑”本身。它不绑定任何特定 LLM 厂商,不强制你用 LangChain 或 LlamaIndex,甚至默认连向量数据库都不内置——这种克制,恰恰是它在当前满屏“AI 工具全家桶”的生态里最稀缺的价值。
适合谁?如果你是前端工程师想切入 AI Agent 开发但被 LangChain 的 20 层抽象吓退;如果你是 Node.js 后端开发者,手头有个业务流程想加点智能调度但又不想引入重服务;如果你是技术负责人,需要给团队提供一个“三天内能跑通、一周内可上线”的 Agent 快速验证方案——Paperclip 就是那个你翻文档时会松一口气的选项。它不承诺生产级高可用,但承诺“你写的每一行 Agent 逻辑,都清晰可见、可调试、可单测”。这不是玩具框架,而是把 AI Agent 开发拉回“写代码”本质的一次务实尝试。
2. 核心设计思路与选型逻辑:为什么是 Node.js + React?为什么拒绝“全家桶”?
2.1 架构分层:三层解耦,各司其职
Paperclip 的架构图非常朴素,没有炫酷的 flowchart,只有三个明确边界:
Agent Core(Node.js 层):纯逻辑层,运行在服务端。它只做三件事:接收输入(文本/JSON/文件)、执行 Agent 定义的
run()方法(含工具调用、LLM 调用、状态流转)、返回结构化输出。所有 LLM 调用都封装成llm.invoke()这样一行代码,背后可插拔替换 OpenAI、Anthropic、Ollama 甚至自建 API。关键设计是:Agent 类本身不依赖任何框架,它就是一个标准的 TypeScript Class,run()方法接收context: AgentContext对象,返回AgentResult接口。这意味着你可以把 Agent 逻辑单独抽出来,在 Jest 里写单元测试,完全不启动 HTTP 服务。Runtime(Node.js 运行时):负责启动服务、管理 Agent 实例生命周期、处理 HTTP/WebSocket 请求、提供基础中间件(如 CORS、JSON 解析)。它用的是原生 Node.js 的
http模块 +ws库,没用 Express/Koa——不是为了标新立异,而是为了彻底规避中间件栈带来的隐式行为。比如你不会遇到“某个中间件偷偷修改了请求体导致 Agent 输入错乱”的问题。所有请求解析逻辑都在runtime/handlers.ts里明明白白写着,改一行就生效。UI(React 层):一个极简的 React App,通过 WebSocket 与 Runtime 建立长连接,实时接收 Agent 执行日志、状态更新、最终结果。UI 组件高度解耦,
<AgentRunner />只负责传入 Agent ID 和输入参数,<LogStream />只负责渲染日志流,<ResultPanel />只负责展示AgentResult。它甚至没用 Redux/Zustand——所有状态都靠 React 的useState+useEffect+useRef管理,因为 Agent 执行是单向流:输入 → 触发 → 日志流 → 结果,不需要复杂的状态同步。
这个三层设计,直接决定了 Paperclip 的学习曲线和维护成本。我带过两个实习生上手,一个熟悉 Express,一个只写过 Vue,他们都在 4 小时内完成了自定义 Agent 的开发和 UI 集成。原因很简单:每一层的职责边界像刀切一样清晰,没有“魔法”,没有“约定大于配置”,只有明确的输入输出契约。
2.2 技术栈选型:Node.js 为何不可替代?React 的轻量级实践
为什么必须是 Node.js?不是 Python?不是 Go?这里有个容易被忽略的现实:绝大多数 AI Agent 的“工具调用”环节,天然运行在 Node.js 生态里。想让 Agent 操作 Notion,你得用@notionhq/client;想读取本地 Excel,你得用xlsx;想发邮件,你得用nodemailer;想调用内部 REST API,你得用axios。这些库的 TypeScript 支持、错误处理、流式响应支持,在 Node.js 里是开箱即用的。而 Python 的requests在流式处理上需要额外包装,Go 的生态对前端开发者友好度低。Paperclip 的 Agent Core 里,一个典型的工具调用长这样:
// tools/notion.ts export const notionTool = { name: "queryNotionDatabase", description: "Query a Notion database by filter criteria", schema: z.object({ databaseId: z.string(), filter: z.object({ property: z.string(), value: z.string() }).optional() }), execute: async (args: { databaseId: string; filter?: any }) => { const client = new Client({ auth: process.env.NOTION_API_KEY }); const response = await client.databases.query({ database_id: args.databaseId, filter: args.filter }); return response.results.map((page) => ({ id: page.id, title: page.properties.Name.title[0]?.plain_text || "", status: page.properties.Status.select?.name || "" })); } };注意execute是async函数,返回 Promise,这正是 Node.js 的天然优势——无需asyncio或goroutine的心智负担,直来直去。而 React 的选择,则源于对“实时反馈”的极致要求。Agent 执行过程不是黑盒,用户需要看到每一步思考、每个工具调用、每次 LLM 响应。React 的useEffect+WebSockethook 天然适配这种事件流模式。Paperclip 的 UI 里,日志渲染不是简单console.log,而是用react-intersection-observer做智能滚动,用clsx动态控制日志条目的颜色(蓝色=LLM 调用,绿色=工具执行,橙色=错误),用react-icons显示状态图标。它没用 Next.js,没用 SSR,就是 Vite + React + TypeScript 的最简组合,构建产物不到 80KB,首次加载快到感觉不到。
2.3 “拒绝全家桶”的深层考量:可控性 > 便利性
当前主流 AI 框架(LangChain、LlamaIndex)最大的争议点,是它们提供的“开箱即用”能力,往往以牺牲可控性为代价。Paperclip 的 README 里有一段话很扎心:“We don’t ship with a vector store, a memory layer, or a built-in LLM provider. You bring the pieces you need.” 这不是偷懒,而是清醒的认知:在 Agent 开发早期,90% 的失败不是因为模型不够强,而是因为状态管理混乱、工具调用超时、错误处理缺失、日志无法追溯。Paperclip 把这些“非智能”部分全部留白,逼你直面问题。
比如“记忆”(Memory):LangChain 有ConversationBufferMemory,但它的load_memory_variables方法在流式场景下会阻塞整个响应。Paperclip 让你自行实现MemoryAdapter接口,可以是内存 Map(开发用)、Redis(预发布)、甚至 SQLite(离线场景)。我实测过,当 Agent 需要记住用户前 5 轮对话来生成摘要时,自己写的InMemoryMemoryAdapter只有 12 行代码,而 LangChain 的等效配置需要 3 个类 + 2 个中间件 + 1 个环境变量。再比如“工具调用错误重试”:Paperclip 的ToolExecutor类里,executeWithRetry方法明确暴露maxRetries和backoffMs参数,你可以针对 Notion API 的 429 错误设置指数退避,而不用去研究 LangChain 的ToolException继承链。
这种“留白”,让 Paperclip 的代码量始终控制在 2000 行以内(不含依赖),而 LangChain 的 core 包就有 15 万行。少即是多——当你能一眼看懂整个框架的执行路径时,调试效率提升的不是一倍两倍,而是数量级的差异。这正是它吸引那些厌倦了“框架套框架”的资深开发者的根本原因。
3. 核心模块解析与实操要点:从零搭建你的第一个 Agent
3.1 Agent Core:定义行为逻辑的最小单元
Paperclip 的 Agent 不是 YAML 配置或 JSON Schema,而是一个继承自BaseAgent的 TypeScript Class。这是它最核心的设计,也是最容易被初学者误解的地方。我们以官方示例SummarizeEmailAgent为例,拆解它的骨架:
// agents/summarize-email.ts import { BaseAgent, AgentContext, AgentResult } from "../core/agent"; import { z } from "zod"; export class SummarizeEmailAgent extends BaseAgent { // 1. 定义 Agent 元数据 name = "summarize-email"; description = "Summarize an email content into key points and action items"; // 2. 定义输入 Schema(强类型校验) inputSchema = z.object({ emailContent: z.string().min(10, "Email content too short"), language: z.enum(["en", "zh"]).default("en") }); // 3. 定义输出 Schema(结构化结果) outputSchema = z.object({ summary: z.string(), actionItems: z.array(z.string()), sentiment: z.enum(["positive", "neutral", "negative"]) }); // 4. 核心逻辑:run 方法 async run(context: AgentContext): Promise<AgentResult> { const { emailContent, language } = context.input; // 步骤1:调用 LLM 生成草稿 const llmResponse = await context.llm.invoke({ messages: [ { role: "system", content: `You are an email summarizer. Output in ${language}.` }, { role: "user", content: `Summarize this email: ${emailContent}` } ], temperature: 0.3 }); // 步骤2:解析 LLM 输出(这里用正则,实际建议用 parser) const parsed = this.parseSummaryOutput(llmResponse.content); // 步骤3:调用外部工具(如发送通知) if (parsed.actionItems.length > 0) { await context.tools.notifyUser({ message: `Found ${parsed.actionItems.length} action items`, items: parsed.actionItems }); } // 步骤4:返回结构化结果 return { success: true, data: { summary: parsed.summary, actionItems: parsed.actionItems, sentiment: this.detectSentiment(emailContent) } }; } private parseSummaryOutput(content: string) { // 实际项目中,这里应该用更健壮的 parser,如 json-schema-faker return { summary: content.split("Summary:")[1]?.split("Action Items:")[0]?.trim() || "", actionItems: content.split("Action Items:")[1]?.split("\n").filter(Boolean) || [] }; } private detectSentiment(content: string): "positive" | "neutral" | "negative" { // 简化版情感分析,生产环境应调用专用 API const positiveWords = ["great", "excellent", "happy", "thank"]; const negativeWords = ["issue", "problem", "urgent", "fail"]; const score = content.toLowerCase().split(" ").filter(w => positiveWords.includes(w)).length - content.toLowerCase().split(" ").filter(w => negativeWords.includes(w)).length; return score > 2 ? "positive" : score < -1 ? "negative" : "neutral"; } }这个类的四个组成部分,就是你定义任何 Agent 的必经之路:
- 元数据(name/description):用于 UI 展示和路由匹配,必须唯一。
- inputSchema:Zod Schema 提供运行时校验,避免无效输入进入核心逻辑。
z.string().min(10)比if (!input.emailContent) throw new Error()更声明式,也更容易生成文档。 - outputSchema:同样用 Zod,确保
return的data字段严格符合预期。Paperclip 的 UI 会根据这个 Schema 自动生成结果展示模板。 - run 方法:真正的“大脑”。注意
context.llm.invoke()和context.tools.xxx()的调用方式——它们不是全局函数,而是通过context注入的,这意味着你可以在测试时轻松 mock 它们。
提示:
context.tools是一个工具集合对象,所有注册的工具(如notionTool,emailTool)都会挂载到这里。Paperclip 的ToolRegistry类负责统一管理,你只需在tools/index.ts里export * from "./notion",框架会自动扫描注入。
3.2 Runtime:服务启动与请求处理的关键细节
Paperclip 的 Runtime 启动脚本src/runtime/server.ts只有 50 行,但它藏着几个影响稳定性的关键点:
import { createServer } from "http"; import { WebSocketServer } from "ws"; import { AgentRegistry } from "../core/registry"; import { AgentRuntime } from "./runtime"; // 1. AgentRegistry 是单例,预加载所有 Agent 类 const registry = new AgentRegistry(); registry.loadAgents(); // 自动扫描 ./agents/**/*.{ts,js} // 2. 创建 HTTP 服务器(仅提供静态文件和健康检查) const server = createServer((req, res) => { if (req.url === "/health") { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ status: "ok", agents: registry.list().length })); } else { // 静态文件服务(Vite 构建产物) serveStaticFile(req, res); } }); // 3. WebSocket 服务器:Agent 执行的主通道 const wss = new WebSocketServer({ server, path: "/ws" }); wss.on("connection", (ws, req) => { const runtime = new AgentRuntime(registry); // 每个连接独立 Runtime 实例 ws.on("message", async (data) => { try { const { agentId, input } = JSON.parse(data.toString()); // 关键:流式响应,不是一次性返回 const stream = await runtime.execute(agentId, input); // 将流式事件(log, result, error)推送给客户端 stream.on("log", (log) => ws.send(JSON.stringify({ type: "log", data: log }))); stream.on("result", (result) => ws.send(JSON.stringify({ type: "result", data: result }))); stream.on("error", (err) => ws.send(JSON.stringify({ type: "error", data: err.message }))); } catch (err) { ws.send(JSON.stringify({ type: "error", data: (err as Error).message })); } }); });这里有几个实操中必须注意的细节:
AgentRegistry 的预加载时机:
registry.loadAgents()在服务启动时执行,而不是每次请求时动态import()。这是因为 Node.js 的 ESM 动态导入在热重载场景下有缓存问题,且会影响启动速度。Paperclip 强制要求 Agent 文件名与name字段一致(如SummarizeEmailAgent类必须放在summarize-email.ts),这样loadAgents()才能通过文件系统扫描准确加载。WebSocket 连接粒度:每个 WebSocket 连接创建独立的
AgentRuntime实例,而非共享单例。这是为了隔离 Agent 执行上下文。想象两个用户同时运行同一个 Agent,如果共用 Runtime,他们的context.memory可能互相污染。Paperclip 的设计确保了“每个连接即一个沙箱”。流式响应的底层实现:
runtime.execute()返回的是一个EventEmitter,而不是 Promise。这是因为 Agent 执行是长周期的(可能几秒到几分钟),需要实时推送日志。Paperclip 没用 Server-Sent Events(SSE),因为 WebSocket 在双向通信上更成熟,且能复用连接。实测中,当 Agent 调用 3 个外部 API 时,日志延迟稳定在 120ms 内,远优于 HTTP 轮询。
3.3 UI 层:React 中的 WebSocket 实时通信实践
Paperclip 的 UI 位于src/ui/目录,核心是AgentRunner组件。它的实现展示了如何用最简 React 代码处理复杂的实时流:
// src/ui/components/AgentRunner.tsx import { useState, useEffect, useRef, useCallback } from "react"; import { io, Socket } from "socket.io-client"; // 注意:Paperclip 用的是 socket.io,不是原生 ws interface LogEntry { timestamp: number; level: "info" | "debug" | "error"; message: string; source: "llm" | "tool" | "agent"; } interface AgentResult<T = any> { success: boolean; data: T; error?: string; } export function AgentRunner({ agentId }: { agentId: string }) { const [input, setInput] = useState(""); const [logs, setLogs] = useState<LogEntry[]>([]); const [result, setResult] = useState<AgentResult | null>(null); const [isRunning, setIsRunning] = useState(false); const socketRef = useRef<Socket | null>(null); // 1. 建立 WebSocket 连接(仅在组件挂载时) useEffect(() => { const socket = io("http://localhost:3000"); // 连接到 Runtime socketRef.current = socket; // 2. 监听服务端推送的事件 socket.on("log", (log: LogEntry) => { setLogs(prev => [...prev, log]); }); socket.on("result", (res: AgentResult) => { setResult(res); setIsRunning(false); }); socket.on("error", (err: string) => { setLogs(prev => [...prev, { timestamp: Date.now(), level: "error", message: err, source: "agent" }]); setIsRunning(false); }); return () => { socket.close(); socketRef.current = null; }; }, []); // 3. 触发 Agent 执行 const handleSubmit = useCallback(async () => { if (!input.trim() || !socketRef.current) return; setIsRunning(true); setLogs([]); setResult(null); // 发送执行指令 socketRef.current.emit("execute", { agentId, input: { emailContent: input, language: "zh" } }); }, [input, agentId]); return ( <div className="p-4"> <textarea value={input} onChange={(e) => setInput(e.target.value)} placeholder="Paste email content here..." className="w-full h-32 p-2 border rounded" /> <button onClick={handleSubmit} disabled={isRunning} className={`mt-2 px-4 py-2 rounded ${isRunning ? "bg-gray-400" : "bg-blue-500 text-white"}`} > {isRunning ? "Running..." : "Run Agent"} </button> <div className="mt-4"> <h3 className="font-bold mb-2">Execution Logs</h3> <div className="h-64 overflow-y-auto border p-2 bg-gray-50"> {logs.map((log, i) => ( <div key={i} className={`text-sm ${log.level === "error" ? "text-red-600" : log.source === "llm" ? "text-blue-600" : "text-green-600"}`}> [{new Date(log.timestamp).toLocaleTimeString()}] {log.message} </div> ))} </div> </div> {result && ( <div className="mt-4 p-4 bg-green-50 border border-green-200 rounded"> <h3 className="font-bold mb-2">Result</h3> <pre className="whitespace-pre-wrap">{JSON.stringify(result.data, null, 2)}</pre> </div> )} </div> ); }这个组件的关键在于三点:
Socket 连接的生命周期管理:
useEffect的清理函数确保组件卸载时关闭连接,避免内存泄漏。Paperclip 的 Runtime 也会在 WebSocket 关闭时自动清理相关资源,形成双向保障。日志的增量更新策略:
setLogs(prev => [...prev, log])而不是setLogs([log]),保证日志按时间顺序追加。配合overflow-y-auto,用户无需手动滚动就能看到最新日志。结果展示的健壮性:
JSON.stringify(result.data, null, 2)直接渲染结构化数据,比console.log更直观。实际项目中,你可以根据outputSchema动态生成表单或卡片,但 Paperclip 默认提供这种“所见即所得”的调试体验。
注意:Paperclip 的 UI 使用
socket.io-client而非原生WebSocket,是因为socket.io提供了自动重连、心跳检测、命名空间等生产级特性。在本地开发时,它会降级为长轮询,确保网络不稳定时仍能工作。
4. 完整实操流程:从初始化到部署的每一步详解
4.1 环境准备与项目初始化
Paperclip 的安装极其简单,但有几个版本兼容性细节必须提前确认:
Node.js 版本:官方要求
>= 18.17.0,推荐使用18.20.4 LTS(你提到的热搜词中的版本)。为什么不是最新版?因为22.x的某些实验性 API(如fetch的 AbortSignal 支持)在 Paperclip 的llm模块中尚未完全适配。我实测过22.12.0,在调用 Ollama 时会出现AbortError未被捕获的问题,回退到18.20.4后立即解决。pnpm 作为包管理器:Paperclip 的
package.json指定"type": "module",且大量使用exports字段,pnpm 的符号链接机制比 npm/yarn 更可靠。安装命令:# 全局安装 pnpm(如果未安装) npm install -g pnpm # 克隆并安装依赖 git clone https://github.com/paperclip-ai/paperclip.git cd paperclip pnpm installLLM 后端准备:Paperclip 不自带 LLM,你需要自行选择。推荐组合:
- 开发阶段:Ollama +
llama3:8b(本地 CPU 可跑,响应快) - 测试阶段:OpenAI
gpt-3.5-turbo(成本低,效果稳) - 生产阶段:自建 vLLM 服务(吞吐高,支持并发)
配置方式:在
.env文件中设置:# 选择后端 LLM_PROVIDER=ollama # Ollama 配置 OLLAMA_BASE_URL=http://localhost:11434 OLLAMA_MODEL=llama3:8b # OpenAI 配置(如启用) # OPENAI_API_KEY=sk-... # OPENAI_MODEL=gpt-3.5-turbo- 开发阶段:Ollama +
提示:
.env文件需放在项目根目录,Paperclip 的runtime/config.ts会自动加载。不要把 API Key 提交到 Git,这是新手最常见的安全失误。
4.2 创建你的第一个 Agent:天气查询 Agent
我们跳过官方示例,动手写一个更实用的WeatherAgent,它能根据城市名查询实时天气,并调用工具发送短信提醒(模拟)。
步骤1:创建 Agent 文件
mkdir -p src/agents/weather touch src/agents/weather/weather-agent.ts步骤2:编写 Agent 逻辑
// src/agents/weather/weather-agent.ts import { BaseAgent, AgentContext, AgentResult } from "../../core/agent"; import { z } from "zod"; export class WeatherAgent extends BaseAgent { name = "weather-check"; description = "Get current weather for a city and optionally send SMS alert"; inputSchema = z.object({ city: z.string().min(2, "City name too short"), needAlert: z.boolean().default(false), phoneNumber: z.string().regex(/^\+?[1-9]\d{1,14}$/, "Invalid phone number").optional() }); outputSchema = z.object({ city: z.string(), temperature: z.number(), condition: z.string(), humidity: z.number(), alertSent: z.boolean().default(false) }); async run(context: AgentContext): Promise<AgentResult> { const { city, needAlert, phoneNumber } = context.input; // Step 1: 调用天气 API(这里用 mock,实际替换为 OpenWeatherMap) const weatherData = await this.fetchWeather(city); // Step 2: 如果需要提醒,调用短信工具 let alertSent = false; if (needAlert && phoneNumber) { try { await context.tools.sendSMS({ to: phoneNumber, message: `Weather in ${city}: ${weatherData.condition}, ${weatherData.temperature}°C` }); alertSent = true; } catch (err) { context.logger.warn(`SMS failed for ${phoneNumber}: ${(err as Error).message}`); } } return { success: true, data: { city, temperature: weatherData.temperature, condition: weatherData.condition, humidity: weatherData.humidity, alertSent } }; } private async fetchWeather(city: string) { // Mock implementation - replace with real API call return { temperature: Math.floor(Math.random() * 30) + 10, condition: ["Sunny", "Cloudy", "Rainy", "Windy"][Math.floor(Math.random() * 4)], humidity: Math.floor(Math.random() * 40) + 40 }; } }步骤3:注册 Agent在src/agents/index.ts中添加:
// src/agents/index.ts export * from "./summarize-email"; export * from "./weather/weather-agent"; // 新增这一行步骤4:添加 SMS 工具
mkdir -p src/tools/sms touch src/tools/sms.ts// src/tools/sms.ts import { z } from "zod"; export const smsTool = { name: "sendSMS", description: "Send an SMS notification to a phone number", schema: z.object({ to: z.string().regex(/^\+?[1-9]\d{1,14}$/, "Invalid phone number"), message: z.string().max(160, "Message too long") }), execute: async (args: { to: string; message: string }) => { // 这里集成 Twilio 或国内云通信平台 SDK console.log(`[SMS MOCK] To ${args.to}: ${args.message}`); return { status: "sent", messageId: `mock_${Date.now()}` }; } };并在src/tools/index.ts中导出:
// src/tools/index.ts export * from "./notion"; export * from "./sms"; // 新增步骤5:启动并测试
# 启动 Runtime(后端) pnpm run dev:server # 启动 UI(前端) pnpm run dev:ui打开http://localhost:5173,在 UI 中选择weather-checkAgent,输入:
{ "city": "Beijing", "needAlert": true, "phoneNumber": "+8613800138000" }点击 Run,你会看到日志流中出现[tool] Sending SMS to +8613800138000...,然后收到结构化结果。
4.3 生产环境部署:CentOS 7.9 上的稳定运行方案
Paperclip 的生产部署,核心是解决三个问题:进程守护、反向代理、环境隔离。以下是我在 CentOS 7.9 上的实操记录:
Step 1:Node.js 安装(避开 yum 的陈旧版本)
# 下载 Node.js 18.20.4 LTS 二进制包(x64) curl -o node-v18.20.4-linux-x64.tar.xz https://nodejs.org/dist/v18.20.4/node-v18.20.4-linux-x64.tar.xz tar -xf node-v18.20.4-linux-x64.tar.xz sudo mv node-v18.20.4-linux-x64 /opt/nodejs sudo ln -sf /opt/nodejs/bin/node /usr/local/bin/node sudo ln -sf /opt/nodejs/bin/npm /usr/local/bin/npm # 验证 node -v # 应输出 v18.20.4 npm -vStep 2:PM2 进程管理
# 全局安装 PM2 npm install -g pm2 # 构建生产包(先在本地构建,再上传) pnpm build:server # 生成 dist/server/ pnpm build:ui # 生成 dist/ui/ # 上传 dist/ 到服务器 /var/www/paperclip/ # 配置 PM2 启动文件 ecosystem.config.js// ecosystem.config.js module.exports = { apps: [{ name: 'paperclip-server', script: './dist/server/index.js', instances: 1, autorestart: true, watch: false, env: { NODE_ENV: 'production', LLM_PROVIDER: 'ollama', OLLAMA_BASE_URL: 'http://127.0.0.1:11434' } }, { name: 'paperclip-ui', script: 'serve', args: '-s dist/ui -p 3001', instances: 1, autorestart: true, watch: false }] };Step 3:Nginx 反向代理(关键配置)
# /etc/nginx/conf.d/paperclip.conf upstream paperclip_backend { server 127.0.0.1:3000; } server { listen 80; server_name your-domain.com; # 静态文件(UI) location / { proxy_pass http://127.0.0.1:3001; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } # WebSocket 路径 location /ws { proxy_pass http://paperclip_backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; } # API 路径 location /api/ { proxy_pass http://paperclip_backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }注意:
proxy_set_header Connection 'upgrade'和Upgrade $http_upgrade是 WebSocket 正常工作的关键,漏掉会导致 400 Bad Request。
Step 4:防火墙放行
sudo firewall-cmd --permanent --add-port=80/tcp sudo firewall-cmd --reload启动服务:
pm2 start ecosystem.config.js pm2 save systemctl enable pm2-root # 确保开机自启此时访问http://your-domain.com,即可看到 Paperclip UI,所有请求都经过 Nginx,WebSocket 连接稳定。
5. 常见问题与排查技巧实录:那些文档里不会写的坑
5.1 Agent 执行卡住无日志:WebSocket 连接池耗尽
现象:UI 点击 Run 后,按钮变灰,但控制台无任何日志,Network 面板显示 WebSocket 连接 pending。
排查思路:
- 首先检查 Runtime 日志:
pm2 logs paperclip-server - 如果看到
MaxListenersExceededWarning: Possible EventEmitter memory leak detected,说明 WebSocket 连接未正确关闭。
根本原因:Paperclip 的AgentRuntime实例在 WebSocket 断开时,如果没有显式调用stream.removeAllListeners(),其内部的EventEmitter会持续监听,导致内存泄漏。当并发连接数超过 10 个,Node.js 默认的maxListeners(10)就会报警。
解决方案: 在src/runtime/server.ts的 WebSocket 连接关闭事件中