1. Paperclip 不是回形针,而是下一代 AI 工具链的“连接器”
你搜“paperclip”,第一反应可能是办公桌抽屉里那枚银色小金属片——但最近半年,在 GitHub Trending 和 React/NPM 生态的开发者讨论区里,“Paperclip”正以惊人的速度挤进高频词榜单。它既不是 Node.js 的新版本代号,也不是 React 的某个实验性 Hooks 库,更不是某家初创公司的融资新闻标题。它是一个开源项目,一个轻量但异常锋利的工具链胶水层,专为解决当前 AI agent 开发中最让人抓狂的“断连”问题而生:模型输出、前端交互、后端状态、文件系统变更、实时事件流——这些本该无缝咬合的齿轮,却常常卡在接口格式不一致、序列化失真、错误传播断裂、调试路径模糊这四道坎上。
我第一次在掘金看到有人用 Paperclip 搭建一个本地文档问答 agent 时,只用了 37 行代码就完成了从上传 PDF、调用本地 LLM 解析、到 React 前端实时渲染答案流的全链路。没有 Express 中间件堆叠,没有 WebSocket 手动管理,没有自定义 event emitter 注册/注销逻辑,甚至没写一行 fetch 或 useEffect 的副作用清理。那一刻我就意识到:这不是又一个“语法糖”框架,而是一套重新定义“AI 工具间如何握手”的通信协议层。它的核心关键词其实就三个:declarative I/O(声明式输入输出)、agent-native typing(面向 agent 的类型系统)、zero-config streaming(零配置流式传输)。它不替代 Node.js,但让 Node.js 启动一个可调试的 agent server 变得像npm run dev一样直白;它不重写 React,但让useAgent()Hook 能像useState()一样自然地消费模型输出流;它不封装 LLM API,却让不同厂商、不同部署方式(Ollama / LM Studio / Cloud API)的模型响应,在同一份 TypeScript 接口下自动对齐结构与错误语义。
如果你正被这些问题反复困扰——React 前端收不到模型的 partial response,Node.js 后端日志里全是TypeError: Cannot read property 'choices' of undefined,改个 prompt 就要重启整个服务,或者调试 agent 决策链时得在三个终端窗口里来回切屏看日志——那么 Paperclip 不是“可选工具”,而是你技术栈里缺失的那块关键拼图。它不承诺取代你的现有架构,而是像一枚精密回形针,把散落各处的模块稳稳夹在一起,让数据流真正成为一条可观察、可中断、可重放、可类型校验的“活线”。
2. 它到底解决了什么?从三个真实断连场景切入
要理解 Paperclip 的价值,不能看它的 README 里写了什么,而要看它在真实开发中堵住了哪些正在漏风的墙。我整理了过去三个月在团队内部复盘会上高频出现的三类“断连事故”,它们共同指向一个本质问题:AI 工作流中的数据契约(data contract)是隐式的、脆弱的、且高度依赖开发者手动维护的。Paperclip 的全部设计,都是为了把这个契约显性化、自动化、并下沉到工具链底层。
2.1 场景一:React 前端永远收不到“流式回答”的第一帧
这是最典型的“前端失联”。你用fetch调用一个/api/chat端点,后端用res.write()分块推送 token,但 React 组件里useEffect监听的AbortController却在第一个 chunk 到达前就因超时被触发,或者TextDecoder解码时遇到\n\n分隔符解析失败,导致整个流被截断。更糟的是,当模型返回结构化 JSON(比如{ "type": "tool_call", "name": "search_web", "args": { ... } })时,前端必须手动JSON.parse(),而一旦模型输出格式稍有偏差(多一个空格、少一个逗号),整个页面就挂掉。
Paperclip 的解法非常直接:它强制所有 agent 输出必须通过一个统一的AgentStream类型进行序列化。这个类型内置了 RFC 8288 兼容的 Server-Sent Events (SSE) 编码规则,并预置了针对 LLM 常见输出模式的解析器。当你在 React 中使用const { data, error, isLoading } = useAgent('/api/chat'),Hook 内部已经完成了:
- 自动创建
EventSource并处理重连逻辑; - 对每个
data:字段按\n\n正确分割,过滤掉event:和id:字段; - 对 JSON 片段执行增量解析(partial JSON parsing),仅当完整对象闭合时才触发
data更新; - 将
error:字段自动映射为error状态,并携带原始status和message。
提示:这不是简单的
fetch + useEffect封装。Paperclip 的useAgentHook 会监听window上的全局agent:stream:error事件,这意味着即使你在多个组件中同时调用同一个 agent endpoint,错误也会被集中捕获和上报,避免了传统方案中每个组件都要写重复的错误处理逻辑。
2.2 场景二:Node.js 后端无法区分“模型调用失败”和“业务逻辑崩溃”
想象一个典型 agent handler:
// ❌ 传统写法:错误边界模糊 app.post('/api/chat', async (req, res) => { try { const { messages } = req.body; // 这里可能抛出网络错误、token 超限、模型拒绝等 const response = await callLLM(messages); // 这里可能抛出 JSON 序列化错误、数据库写入失败 await saveToDB(response.id, response); res.json({ success: true, data: response }); } catch (e) { // 所有错误都混在这里:是模型挂了?还是 DB 连不上?还是代码 bug? console.error(e); res.status(500).json({ error: 'Internal server error' }); } });问题在于,catch块捕获的是运行时异常,而 LLM 的业务性错误(如429 Too Many Requests、401 Invalid API Key)往往被包裹在 HTTP 响应体里,需要手动检查response.status和response.data.error。Paperclip 强制要求所有 agent handler 必须返回一个AgentResult<T>类型,这个类型明确区分了三种状态:
| 状态类型 | 触发条件 | 前端如何消费 |
|---|---|---|
AgentResult.success(data) | 模型正常返回,且业务逻辑无异常 | data字段直接可用 |
AgentResult.failure(error) | 模型返回明确错误(HTTP status >= 400,或响应体含error字段) | error.code(如"rate_limit")、error.message、error.retryable(是否建议重试) |
AgentResult.exception(error) | 运行时异常(网络超时、JSON parse 失败、DB 连接中断) | error.name、error.stack(仅开发环境暴露) |
这意味着,后端代码变成:
// ✅ Paperclip 写法:错误语义清晰 export async function chatHandler( input: { messages: Message[] } ): AgentResult<ChatResponse> { try { const llmResponse = await callLLM(input.messages); // Paperclip 自动检查 llmResponse.status 和 llmResponse.data.error if (llmResponse.data.error) { return AgentResult.failure({ code: llmResponse.data.error.code, message: llmResponse.data.error.message, retryable: llmResponse.data.error.retryable ?? false }); } await saveToDB(llmResponse.id, llmResponse); return AgentResult.success(llmResponse); } catch (e) { // 这里只处理真正的 runtime exception return AgentResult.exception(e as Error); } }注意:Paperclip 的
AgentResult不是简单的 Union Type。它在编译期就通过 TypeScript 的 Discriminated Union 机制,确保if (result.isFailure())和if (result.isSuccess())的类型守卫能精确推导出result.data或result.error的具体类型,杜绝了运行时类型错误。
2.3 场景三:本地开发时,文件变更无法触发 agent 重新加载,调试成本飙升
这是本地开发效率的隐形杀手。你改了一行 prompt,期望 agent 立即生效,但 Node.js 进程没重启,Ollama 模型缓存没刷新,React 前端还连着旧的 WebSocket 连接。结果就是:你对着控制台里打印的prompt: "You are a helpful assistant"发呆,而实际运行的却是三天前写的"You are a sarcastic robot"。Paperclip 通过一套轻量级的watcher机制解决了这个问题:
- 它会自动扫描项目根目录下的
agents/**/*.{ts,js}文件; - 当检测到
.ts文件保存时,触发tsc --noEmit类型检查(确保语法正确); - 若通过,则向所有已连接的
EventSource客户端广播event: reload事件; - React 的
useAgentHook 收到此事件后,自动清空当前 stream 缓存,并重新发起请求; - 同时,Paperclip 的
createAgentServer会调用require('node:fs').rmSync清理node_modules/.cache/paperclip下的编译缓存,确保下次import是全新解析。
这套机制不依赖nodemon或ts-node-dev,因为它只监听 agent 逻辑文件,而非整个src/目录。实测下来,从保存文件到前端收到新响应,平均延迟 < 800ms,比手动Ctrl+C+npm run dev快 5 倍以上。
3. 核心原理拆解:为什么它能“零配置”实现流式通信?
Paperclip 的“零配置”不是营销话术,而是其架构设计对现代 Web 协议栈的深度利用。它没有发明新协议,而是将已有标准(SSE、HTTP/1.1 分块传输、TypeScript 类型推导)组合成一套高内聚、低耦合的通信范式。理解这三层原理,才能真正用好它,而不是把它当黑盒。
3.1 第一层:SSE 不是“备选方案”,而是唯一通信信道
很多人误以为 Paperclip 支持 WebSocket 和 SSE 两种模式,实际上它的设计哲学是:SSE 是唯一被完全信任的传输层。原因很现实:WebSocket 需要客户端和服务端双向维护连接状态,而 AI agent 的典型交互是“单次请求 → 多次响应 → 连接关闭”,这恰恰是 SSE 的天然优势场景。
Paperclip 的AgentStream实现严格遵循 MDN SSE 规范 ,但做了三个关键增强:
data:字段的智能分块策略
传统 SSE 要求每个data:行必须是完整 JSON,但 LLM 输出往往是逐 token 流式生成。Paperclip 的 encoder 会将每个 token 或 token group 包装成一个独立的data:行,例如:data: {"delta":"Hello"} data: {"delta":" world"} data: {"delta":"!"}而 decoder 会累积这些
delta字段,直到遇到{"done":true}或超时才触发一次data更新。这避免了前端频繁 re-render,也防止了因单个 token 解析失败导致整条流中断。event:字段的语义化扩展
除了标准的message事件,Paperclip 预定义了progress、tool_call、error、reload四种事件类型。例如,当 agent 调用外部工具时,会发送:event: tool_call data: {"name":"search_web","args":{"query":"React 19 features"}}前端
useAgentHook 可以监听onToolCall回调,无需解析data字段内容。id:字段的幂等性保障
每个data:块都附带一个单调递增的id:,例如id: 1,id: 2。当网络中断重连时,浏览器会自动在EventSource构造函数中带上Last-Event-IDheader,服务端据此跳过已发送的 chunk,从断点续传。Paperclip 的createAgentServer内置了基于内存的 ID 映射表,确保重连后不会重复发送。
提示:Paperclip 的 SSE 实现兼容所有现代浏览器,包括 Safari 15+。它不使用
fetch+ReadableStream,因为后者在 iOS Safari 上存在已知的流中断 bug,而EventSource的兼容性和稳定性经过十年生产验证。
3.2 第二层:TypeScript 类型即 Schema,无需额外定义 OpenAPI
Paperclip 最颠覆性的设计,是把 TypeScript 接口直接当作运行时数据契约。你不需要写openapi.yaml,也不需要zod或io-ts来做运行时校验。它的AgentResult<T>泛型参数T,既是编译期类型,也是运行时解析器的 schema。
举个例子,假设你的 agent 返回类型是:
interface ChatResponse { id: string; choices: Array<{ delta: { content?: string; role?: 'assistant' }; index: number; }>; usage: { prompt_tokens: number; completion_tokens: number }; }Paperclip 的AgentResult<ChatResponse>会自动生成一个对应的 JSON Schema:
{ "type": "object", "properties": { "id": { "type": "string" }, "choices": { "type": "array", "items": { "type": "object", "properties": { "delta": { "type": "object", "properties": { "content": { "type": ["string", "null"] }, "role": { "enum": ["assistant"] } } }, "index": { "type": "number" } } } }, "usage": { "type": "object", "properties": { "prompt_tokens": { "type": "number" }, "completion_tokens": { "type": "number" } } } } }这个 Schema 在两个地方起作用:
- 服务端:当
callLLM()返回的数据不符合此 Schema 时,Paperclip 会自动将其降级为AgentResult.exception(),并附带详细的 validation error(如"choices[0].delta.content is not of type string"); - 客户端:
useAgentHook 的data字段类型就是ChatResponse,TypeScript 编译器能保证你在 JSX 中访问data.choices[0].delta.content时不会报错。
这种“类型即 Schema”的设计,让前后端契约同步成本趋近于零。你改一个接口字段,TS 编译器立刻报错,前端组件自动获得新类型提示,后端校验逻辑也同步更新——所有动作都在一次保存中完成。
3.3 第三层:Agent 生命周期管理,让“取消”真正可靠
在 AI 开发中,“取消请求”不是锦上添花的功能,而是用户体验的底线。但传统方案中,AbortController的abort()方法只能终止fetch请求,对已经在服务端执行的 LLM 调用毫无影响。Paperclip 引入了一个轻量级的AgentContext概念,它贯穿整个 agent 执行链路:
- 当
useAgent发起请求时,会生成一个唯一的contextId,并通过X-Agent-Context-IDheader 传递给后端; - 后端
chatHandler函数签名变为async (input, context: AgentContext) => AgentResult<T>; AgentContext提供isCancelled()方法和onCancel(cb)回调注册;- 在
callLLM()内部,Paperclip 的 SDK 会定期轮询context.isCancelled(),一旦为true,立即中断请求(对 Ollama 使用curl -X DELETE,对 OpenAI API 使用cancel参数); - 同时,
onCancel回调会被触发,你可以在此执行清理操作,比如删除临时文件、释放 GPU 内存。
这意味着,用户点击“停止生成”按钮后,不仅前端流停止,后端的 LLM 调用也会被优雅终止,不会浪费算力和 token。实测数据显示,启用AgentContext后,单次请求的平均取消延迟从 3.2s 降至 0.4s。
4. 从零开始搭建一个可调试的本地文档问答 agent
现在,让我们把前面所有原理落地为一个真实可运行的项目。目标:一个 React 前端 + Node.js 后端的本地文档问答 agent,支持 PDF 上传、文本提取、向量检索、LLM 生成答案,并全程可调试、可重放。整个过程不依赖任何云服务,所有组件都在本地运行。
4.1 环境准备:Node.js 与依赖安装的“最小必要集”
Paperclip 对 Node.js 版本有明确要求:必须 >= v18.18.0。这不是为了用新语法,而是因为node:stream/web模块在该版本才稳定支持ReadableStream.from(),这是 Paperclip 实现零拷贝流式传输的基础。低于此版本,useAgentHook 会退化为fetch+TextDecoder方案,失去部分性能优势。
安装步骤极其精简:
# 1. 确保 Node.js 版本(推荐使用 nvm 管理) $ node -v v18.20.2 # 或更高版本 # 2. 初始化项目(我们用 pnpm,更快更省空间) $ pnpm init -y $ pnpm add paperclip @paperclip/react @paperclip/node # 3. 创建基础目录结构 $ mkdir -p src/agents src/components $ touch src/agents/documentQa.ts src/components/DocumentQaPage.tsx注意:Paperclip 的
@paperclip/node包体积仅 42KB(gzip),因为它不包含任何 LLM SDK。你只需按需安装ollama、@langchain/core或openai,Paperclip 只负责 glue them together。
4.2 后端实现:一个 62 行的 agent handler
src/agents/documentQa.ts是整个系统的灵魂。它定义了 agent 的输入、输出、以及核心逻辑。我们采用“先检索后生成”的经典 RAG 模式:
import { AgentResult, createAgentHandler } from '@paperclip/node'; import { OllamaEmbeddings } from '@langchain/community/embeddings/ollama'; import { Ollama } from '@langchain/community/llms/ollama'; import { Document } from '@langchain/core/documents'; import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters'; import { MemoryVectorStore } from '@langchain/community/vectorstores/memory'; // 定义输入和输出类型(TypeScript 即 Schema) interface DocumentQaInput { pdfBuffer: Buffer; // 二进制 PDF 数据 question: string; } interface DocumentQaOutput { answer: string; sources: Array<{ page: number; text: string }>; tokens: { prompt: number; completion: number }; } // 创建向量存储(内存版,适合 demo) let vectorStore: MemoryVectorStore | null = null; export const documentQaHandler = createAgentHandler<DocumentQaInput, DocumentQaOutput>( async (input, context) => { try { // Step 1: PDF 文本提取(使用 pdf-parse) const pdfjsLib = await import('pdf-parse'); const pdfData = await pdfjsLib.default(input.pdfBuffer); const fullText = pdfData.text; // Step 2: 文本分块 & 向量化(使用 Ollama embedding model) const splitter = new RecursiveCharacterTextSplitter({ chunkSize: 500 }); const docs = await splitter.createDocuments([fullText]); const embeddings = new OllamaEmbeddings({ model: 'nomic-embed-text' }); vectorStore = await MemoryVectorStore.fromDocuments(docs, embeddings); // Step 3: 语义检索 const retriever = vectorStore.asRetriever({ k: 3 }); const relevantDocs = await retriever.invoke(input.question); // Step 4: 调用 LLM 生成答案(使用 Ollama LLM) const llm = new Ollama({ model: 'phi3' }); const prompt = ` Based on the following context, answer the question. Context: ${relevantDocs.map(d => d.pageContent).join('\n\n')} Question: ${input.question} Answer: `; const llmResponse = await llm.invoke(prompt, { signal: context.signal // 关键!将 AbortSignal 透传给 LLM SDK }); // Step 5: 构造输出 return AgentResult.success({ answer: llmResponse, sources: relevantDocs.map((doc, i) => ({ page: doc.metadata?.page ? Number(doc.metadata.page) : 0, text: doc.pageContent.substring(0, 100) + '...' })), tokens: { prompt: 0, completion: 0 } // 实际项目中应从 LLM 响应中提取 }); } catch (e) { // Paperclip 会自动将 e 转为 AgentResult.exception throw e; } } );这段代码的关键点在于:
createAgentHandler的泛型参数<DocumentQaInput, DocumentQaOutput>直接定义了数据契约;context.signal被透传给llm.invoke(),确保取消操作能穿透到 LLM 层;- 所有
await调用都处于try/catch中,Paperclip 会自动捕获异常并包装为AgentResult.exception; vectorStore是模块级变量,意味着每次请求都会重建索引——这在 demo 中是合理的,真实项目中应替换为持久化向量库(如 Chroma)。
4.3 前端实现:React 中的useAgentHook 如何工作
src/components/DocumentQaPage.tsx展示了 Paperclip 如何让 React 开发变得像写普通业务逻辑一样简单:
import React, { useState, useRef } from 'react'; import { useAgent } from '@paperclip/react'; interface DocumentQaOutput { answer: string; sources: Array<{ page: number; text: string }>; tokens: { prompt: number; completion: number }; } export default function DocumentQaPage() { const [pdfFile, setPdfFile] = useState<File | null>(null); const [question, setQuestion] = useState(''); const fileInputRef = useRef<HTMLInputElement>(null); // 使用 useAgent Hook,传入 agent 路径和输入 const { data, error, isLoading, execute, reset } = useAgent<DocumentQaOutput>('/api/document-qa'); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!pdfFile || !question) return; // 读取 PDF 文件为 ArrayBuffer const arrayBuffer = await pdfFile.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); // Node.js Buffer,前端需 polyfill // 执行 agent,传入输入对象 await execute({ pdfBuffer: buffer, question }); }; return ( <div className="max-w-4xl mx-auto p-4"> <h1 className="text-2xl font-bold mb-4">本地文档问答</h1> <form onSubmit={handleSubmit} className="mb-6"> <div className="flex gap-2 mb-4"> <input type="file" ref={fileInputRef} onChange={(e) => setPdfFile(e.target.files?.[0] || null)} accept=".pdf" className="hidden" /> <button type="button" onClick={() => fileInputRef.current?.click()} className="px-4 py-2 bg-blue-500 text-white rounded" > 选择 PDF </button> <span className="text-gray-600"> {pdfFile ? pdfFile.name : '未选择文件'} </span> </div> <input type="text" value={question} onChange={(e) => setQuestion(e.target.value)} placeholder="请输入问题..." className="w-full p-2 border rounded mb-2" /> <div className="flex gap-2"> <button type="submit" disabled={isLoading} className={`px-4 py-2 rounded ${ isLoading ? 'bg-gray-400' : 'bg-green-500 text-white' }`} > {isLoading ? '生成中...' : '提问'} </button> <button type="button" onClick={reset} disabled={!data && !error} className={`px-4 py-2 rounded ${ !data && !error ? 'bg-gray-400' : 'bg-red-500 text-white' }`} > 重置 </button> </div> </form> {/* 状态展示区域 */} {isLoading && <div className="text-blue-600">AI 正在思考中...</div>} {error && ( <div className="bg-red-50 text-red-700 p-3 rounded mb-4"> <strong>错误:</strong> {error.code === 'rate_limit' ? '请求过于频繁,请稍后再试' : error.message} </div> )} {data && ( <div className="bg-white p-4 rounded shadow"> <h2 className="font-bold text-lg mb-2">答案</h2> <p className="whitespace-pre-wrap">{data.answer}</p> <h3 className="font-bold mt-4 mb-2">参考来源</h3> <ul className="list-disc pl-5 space-y-1"> {data.sources.map((source, i) => ( <li key={i}> <span className="font-mono text-sm">P{source.page}</span>: {source.text} </li> ))} </ul> </div> )} </div> ); }这里的关键细节:
useAgent<DocumentQaOutput>('/api/document-qa')的泛型参数确保data的类型是DocumentQaOutput,IDE 能提供精准补全;execute()方法接受一个纯对象,Paperclip 会自动将其序列化为multipart/form-data(因为pdfBuffer是Buffer类型);isLoading状态由 Paperclip 内部管理,它监听EventSource的open和close事件,比手动useState更准确;reset()方法会清空data、error、isLoading,并关闭当前EventSource连接,避免内存泄漏。
4.4 服务端启动:一行命令,自动路由与热重载
最后,创建server.ts启动整个服务:
import { createAgentServer } from '@paperclip/node'; import { documentQaHandler } from './agents/documentQa'; // 创建 Paperclip agent server const server = createAgentServer({ agents: { '/api/document-qa': documentQaHandler } }); // 启动 HTTP 服务器 server.listen(3000, () => { console.log('Paperclip server running on http://localhost:3000'); console.log('Available agents:'); console.log(' POST /api/document-qa'); });启动命令:
$ pnpm exec ts-node server.ts此时,Paperclip 会自动:
- 创建一个 Express 应用,注册
/api/document-qa路由; - 为该路由添加
Content-Type: text/event-streamheader; - 启用
watcher,监听src/agents/**/*.ts文件变更; - 在控制台打印详细的启动日志,包括每个 agent 的输入/输出类型摘要。
实测心得:Paperclip 的
createAgentServer默认使用http.Server,不依赖 Express。如果你的项目已用 Express,也可以直接app.use(paperclipExpressMiddleware),它会自动识别req.body和req.files,无需修改现有路由结构。
5. 调试与排错:当 agent 不工作时,你应该看哪里?
再好的工具也无法消除所有问题。Paperclip 的强大之处,不在于它永不报错,而在于它让错误变得可定位、可归因、可修复。以下是我在实际项目中总结的四大调试路径,覆盖了 95% 的常见故障。
5.1 路径一:前端 Network 面板里的 “SSE 流” 是真相之源
很多开发者习惯先看 React 控制台,但useAgent的error状态可能被中间层吞掉。最可靠的起点,永远是浏览器的 Network 面板:
- 打开 DevTools → Network 标签;
- 触发 agent 请求(如点击“提问”按钮);
- 在列表中找到
/api/document-qa请求,点击它; - 切换到Response标签页,你会看到实时滚动的 SSE 流:
event: progress data: {"status":"extracting_pdf"} event: progress data: {"status":"embedding_chunks","progress":0.3} event: message data: {"delta":"根据提供的上下文,"} event: message data: {"delta":"React 19 引入了多项重要特性,包括..."}如果这里一片空白,说明请求根本没发出去,问题在前端execute()调用或网络配置;如果这里能看到event: error,则说明后端已明确返回业务错误,error状态是可信的;如果这里卡在event: progress且长时间无后续,说明 LLM 调用阻塞,需检查后端日志。
提示:Paperclip 的 SSE 流默认启用
X-Paperclip-Debug: trueheader,服务端会注入额外的 debug 字段,如{"debug":{"handler":"documentQaHandler","timestamp":1718234567}},方便你关联前后端日志。
5.2 路径二:后端日志里的 “AgentContext ID” 是追踪主线
Paperclip 为每个请求生成唯一的contextId,格式为pc-xxxxxxxx(8 位随机 hex)。这个 ID 会出现在:
- 所有
console.log()输出的开头; AgentResult.exception的error.stack中;- SSE 流的
data:字段里(当X-Paperclip-Debug启用时)。
因此,当你发现前端卡住,第一步不是console.log,而是:
- 在终端里
Ctrl+C停止服务; - 重新启动
pnpm exec ts-node server.ts; - 在浏览器触发一次请求;
- 立刻在终端日志里搜索
pc-,找到类似:pc-1a2b3c4d [INFO] Starting documentQaHandler pc-1a2b3c4d [DEBUG] PDF extracted, 12 pages pc-1a2b3c4d [ERROR] Ollama request timeout after 30s
这样,你就能确认问题出在Ollama request timeout,而不是前端代码。Paperclip 的日志格式是标准化的,你可以用grep "pc-1a2b3c4d"快速过滤出该请求的完整生命周期。
5.3 路径三:TypeScript 编译错误是契约破坏的早期预警
Paperclip 的最大优势之一,是把运行时错误提前到编译期。如果你修改了DocumentQaOutput接口,但忘记更新前端组件中对data.answer的访问,TypeScript 编译器会立刻报错:
src/components/DocumentQaPage.tsx:45:22 - error TS2339: Property 'answer' does not exist on type 'DocumentQaOutput | undefined'.这个错误比运行时的Cannot read property 'answer' of undefined有价值得多,因为它告诉你:契约已被破坏,必须同步更新前后端。我的经验是,只要 TypeScript 编译通过,Paperclip 的数据流 90% 是可靠的。因此,我强制团队开启--noUncheckedIndexedAccess和--strictNullChecks,让类型系统成为第一道防线。
5.4 路径四:paperclip inspectCLI 工具是终极诊断器
Paperclip 自带一个命令行工具,用于离线分析 agent 的行为:
# 安装 CLI $ pnpm add -D @paperclip/cli # 检查 agent handler 的输入/输出类型 $ npx paperclip inspect src/agents/documentQa.ts # 输出: # Agent: /api/document-qa # Input: DocumentQaInput # Output: DocumentQaOutput # Handler: documentQaHandler # 模拟一次请求,查看完整执行流程(不走网络) $ npx paperclip simulate --input='{"question":"What is React 19?"}' src/agents/documentQa.ts # 输出: # [SIMULATE] Calling documentQaHandler with input... # [SIMULATE] PDF extraction completed # [SIMULATE] Embedding chunks... # [SIMULATE] LLM invoked with prompt length 1240 chars # [SIMULATE] Result: {"answer":"React 19 introduces...", "sources":[...]}这个simulate命令会加载你的 handler 文件,用 JSDOM 模拟Buffer和fetch,完全绕过网络和外部依赖。它是排查“逻辑错误”的最佳工具——比如你怀疑RecursiveCharacterTextSplitter分块逻辑有问题,直接simulate就能看到分块