ARTICLE DETAIL

资讯详情

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

Paperclip 开源 AI Agent 实战:Node.js 与 React 全栈开发指南

Paperclip 开源 AI Agent 实战:Node.js 与 React 全栈开发指南 1. 从“paperclip”这个名字说起它到底想解决什么问题第一次看到“paperclip”这个项目名我脑子里蹦出来的画面是办公桌上那枚最不起眼的回形针。它便宜、简单、几乎没人会特意关注但真到需要把几页纸拢在一起的时候没有它还真不行。一个开源项目敢用这个名字通常意味着两件事要么它想做一件极其基础、极其顺手的小工具要么它想成为把零散信息“夹”在一起的那枚夹子。结合它出现在 Node.js、React、AI agents 这些关键词的交叉地带我倾向于后者——它大概率是一个把 AI 能力接入前端或 Node 服务端的轻量级编排层。先把话说在前面我拿到的原始信息非常少项目正文、关键词、摘要都是空的只有标题和一批相关热词。所以这篇内容不是官方文档的翻译而是我作为一个常年折腾 Node.js 和 React 的人看到这个标题和这批热词之后把“一个叫 paperclip 的开源 AI agent 项目最可能长什么样、该怎么跑起来、坑会埋在哪里”完整推演一遍。如果你正在找类似方向的东西或者手里已经有一个叫 paperclip 的仓库准备上手这篇可以直接当作战地图用。为什么我判断它和 AI agents 强相关因为热词里同时出现了AI agents、手写react agent、ollama webui、开源模型、claude code 超级小白入门指南。这几个词放在一起指向一个非常具体的场景用 Node.js 做后端运行时用 React 做交互界面中间挂一个能调用本地或远程大模型的 agent 循环。paperclip 很可能就是那个“夹子”——把模型输出、工具调用、前端状态更新这三件事夹在一起让它们不散架。这类项目最典型的用户画像有三类。第一类是前端出身、想往 AI 应用方向转的开发者React 熟得不能再熟但对 agent 的循环控制、流式输出、工具注册这些概念还停留在看文章的階段。第二类是 Node.js 后端想给自己的服务加一个“能自己决定调哪个接口”的智能层。第三类就是纯粹想跑一个本地 AI 界面、不想被各种云服务绑住的人。这三类人关心的东西不一样但都会卡在同样的几个地方环境版本、流式传输、状态同步、以及 agent 循环什么时候该停。我后面会按“先搞清楚它是什么 → 环境怎么搭 → 核心循环怎么跑 → 前端怎么接 → 坑在哪”这个顺序往下讲。每一段我都会说清楚为什么这么做而不是只丢命令。因为这类项目最怕的就是照着 README 敲一遍跑是跑起来了但一出问题完全不知道从哪查。2. 拆解 paperclip 的骨架Node.js 运行时、React 界面与 agent 循环的三层结构2.1 为什么这类项目几乎必然选 Node.js 做运行时热词里node.js、node.js安装教程、node.js 18.20.4 lts版本下载、node.js 22.12、centos 7.9 node.js安装部署出现了一大串这不是偶然。一个 AI agent 项目选 Node.js 当运行时核心原因就三条我一条条说。第一条是流式传输的天然契合。大模型的输出是一段一段吐出来的不是一次性给你一个完整 JSON。Node.js 的 Stream 和事件循环模型处理这种“边收边发”的场景非常顺手。你在服务端拿到模型返回的 chunk可以直接 pipe 到 HTTP response前端用EventSource或者fetch的 reader 就能实时渲染。换成某些同步阻塞的运行时你得额外起线程或者用异步框架复杂度立刻上去。第二条是前后端同语言。React 跑在浏览器Node.js 跑在服务端两边都是 JavaScript/TypeScript。这意味着 agent 的工具定义、消息结构、类型声明可以放在一个共享目录里前端和后端引用同一份类型。我做过对比同样一个带工具调用的 agent 项目前后端同语言能省掉至少三分之一的联调时间因为字段名对不上的低级错误在编译期就被拦住了。第三条是生态里现成的 SDK 多。不管是哪家模型服务官方基本都会先出 Node.js 的包。你不需要自己手写 HTTP 请求和重试逻辑装个包就能用。这对一个想快速跑通的开源项目来说是决定性的。那版本怎么选热词里同时出现了 18.20.4 LTS 和 22.12我给的判断是如果你只是跑起来看看用 18.20.4 LTS 最稳因为它是长期支持版绝大多数依赖都测过。如果你想用最新的 fetch、Stream API 或者某些 ESM 特性上 22.12。但要注意Node 22 对某些老依赖的兼容性还在磨合遇到ERR_REQUIRE_ESM这类报错别慌多半是依赖没跟上。# 查看当前版本 node -v npm -v # 如果用 nvm 管理版本切到 18 LTS nvm install 18.20.4 nvm use 18.20.4 # 或者切到 22 nvm install 22.12.0 nvm use 22.12.0提示在 CentOS 7.9 这类老系统上装 Node.js不要直接用系统自带的 yum 源版本太旧。用 nvm 或者 NodeSource 的源能省掉一堆 glibc 版本不匹配的麻烦。2.2 React 在这一层扮演的角色不只是画界面很多人以为 React 在这种项目里就是画个聊天框。这个理解太浅了。React 在 agent 项目里的真正价值是把 agent 的中间状态可视化。一个 agent 跑一次任务中间可能经历“思考 → 决定调工具 → 等工具返回 → 再思考 → 给最终答案”这么多个阶段。如果前端只是等最后结果用户会觉得卡死了。React 的组件化和状态管理正好能把每个阶段拆成独立的 UI 片段。热词里react state与hooks、react 面经、react 图表、react uplot k线图这些词说明用这个项目的人里有很多是在准备前端面试或者做数据可视化的。这其实是个很好的信号paperclip 这类项目的界面层很可能需要展示 agent 的运行轨迹、工具调用耗时、token 消耗曲线这些东西。用uplot画 K 线图听起来离谱但如果你把每次工具调用的耗时当成一根 K 线把 token 消耗当成成交量这套可视化逻辑是通的。我实际做过的方案是用useReducer管理 agent 的消息列表每条消息带一个status字段pending / streaming / done / error。流式更新的时候只更新最后一条消息的 content前面的消息不动。这样 React 的 diff 成本最低不会因为一条消息在流式输出就重渲染整个列表。// 消息状态管理的简化示意 const initialState { messages: [], isRunning: false }; function reducer(state, action) { switch (action.type) { case ADD_MESSAGE: return { ...state, messages: [...state.messages, action.payload] }; case APPEND_CHUNK: { const messages [...state.messages]; const last messages[messages.length - 1]; messages[messages.length - 1] { ...last, content: last.content action.chunk }; return { ...state, messages }; } case SET_STATUS: return { ...state, isRunning: action.payload }; default: return state; } }这段代码的关键在于APPEND_CHUNK只改最后一条消息而不是重建整个数组里的每个对象。别小看这个细节消息一多重建整个列表会让流式输出肉眼可见地卡顿。2.3 agent 循环paperclip 这个名字最可能指代的核心现在说到重点。一个 AI agent 和普通聊天机器人的区别就在于它有一个循环模型输出 → 判断是否需要调工具 → 执行工具 → 把结果塞回上下文 → 再问模型 → 直到模型说“我不需要调工具了”。这个循环就是 paperclip 要“夹住”的东西。为什么叫 paperclip我的理解是它要像回形针一样把“模型的一次输出”和“下一次输入”夹在一起形成一个闭环。这个闭环里最容易出问题的地方有三个循环终止条件、工具调用的错误处理、上下文长度控制。循环终止条件如果写不好agent 会陷入死循环一直调同一个工具。常见的做法是设一个最大轮次比如 10 轮超过就强制停止并返回当前结果。工具调用的错误处理也很关键工具执行失败不能直接让整个 agent 崩掉而应该把错误信息作为工具结果返回给模型让模型自己决定是重试还是换一个工具。上下文长度控制则是老生常谈每轮循环都会往上下文里塞东西塞满了就得截断或者摘要。// agent 循环的骨架逻辑 async function runAgent(userInput, tools, maxTurns 10) { let messages [{ role: user, content: userInput }]; for (let turn 0; turn maxTurns; turn) { const response await callModel(messages, tools); messages.push(response); if (!response.toolCalls || response.toolCalls.length 0) { return response.content; // 模型不再调工具结束 } for (const call of response.toolCalls) { try { const result await executeTool(call.name, call.arguments); messages.push({ role: tool, toolCallId: call.id, content: result }); } catch (err) { messages.push({ role: tool, toolCallId: call.id, content: 工具执行失败: ${err.message} }); } } } return 达到最大轮次已停止; }这段骨架看起来简单但每一行背后都有讲究。maxTurns设多少我一般设 8 到 12太少了复杂任务跑不完太多了浪费 token。工具失败为什么要把错误信息塞回去因为模型看到错误之后有很大概率会换一个参数重试这比直接抛异常给用户友好得多。3. 把 paperclip 跑起来环境准备里那些没人告诉你的细节3.1 Node.js 安装版本、源、以及 CentOS 上的坑热词里node.js安装步骤、node.js配置、如何查看有没有安装node.js、node.js手机端下载这些词说明很多人卡在第一步。我先把最干净的安装路径给你。在 macOS 或 Linux 上我强烈建议用 nvm不要用系统包管理器。原因很简单你迟早会遇到需要切换 Node 版本的情况用系统包管理器装完切换版本要卸载重装非常痛苦。nvm 一行命令搞定。# 安装 nvm curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash # 重新加载 shell 配置 source ~/.bashrc # 或 ~/.zshrc # 安装并使用 Node 18 LTS nvm install 18.20.4 nvm use 18.20.4 nvm alias default 18.20.4 # 验证 node -v # 应输出 v18.20.4 npm -v在 CentOS 7.9 上情况会复杂一点。CentOS 7 自带的 glibc 版本比较老Node 18 之后的某些版本可能跑不起来。我的经验是CentOS 7.9 上优先用 Node 18.20.4这个版本对老系统的兼容性最好。如果一定要上 Node 22先确认 glibc 版本ldd --version # 查看 glibc 版本低于 2.28 的话 Node 22 可能有问题如果 glibc 太低又没法升级系统那就老老实实用 Node 18。别为了追新版本把整个环境搞崩不值得。注意国内网络环境下npm 安装依赖慢是常态。配置镜像源能省很多时间但具体用哪个源我不在这里指定你自己搜一下当前可用的即可。配置命令是npm config set registry 源地址。3.2 依赖安装lock 文件、peer 依赖与原生模块拿到 paperclip 的仓库之后第一步是npm install还是pnpm install看仓库里有没有pnpm-lock.yaml。有就用 pnpm有yarn.lock就用 yarn只有package-lock.json才用 npm。混用包管理器是新手最容易犯的错会导致依赖树不一致出现“我这里能跑你那里报错”的经典问题。安装过程中最常见的三类报错我列个表给你对照报错关键词根本原因处理方式ERESOLVE unable to resolve dependency treepeer 依赖版本冲突先别急着--force看清楚是哪个包的 peer 要求手动装对应版本node-gyp相关错误原生模块编译失败缺 python 或 build tools装python3和build-essentialLinux/ Xcode Command Line ToolsmacOSEACCES权限错误用了 sudo 装全局包导致权限混乱别用 sudo改用 nvm 管理 Node或修复 npm 目录权限node-gyp这个坑我要多说一句。很多 AI 相关的 Node 包会带原生模块比如某些 tokenizer、向量计算库安装时要现场编译。在 Windows 上尤其容易失败因为缺 Visual Studio Build Tools。如果你在 Windows 上折腾建议直接用 WSL2能避开一大半原生模块的坑。3.3 环境变量与模型接入别把密钥写进代码paperclip 要调模型就必然需要配置 API 地址和密钥。我见过太多人直接把密钥硬编码在源码里然后一不小心提交到公开仓库。正确做法是用.env文件并且把.env加进.gitignore。# .env 示例字段名以实际项目为准 MODEL_BASE_URLhttp://localhost:11434/v1 MODEL_API_KEYyour-key-here MODEL_NAMEqwen2.5:7b PORT3000如果你用的是本地模型热词里ollama webui 中文便携版下载 开源镜像指向这个方向MODEL_BASE_URL通常指向本地的推理服务地址MODEL_API_KEY随便填一个非空值就行因为本地服务一般不校验。但要注意本地模型的上下文窗口通常比云端小agent 循环跑到后面容易超限这个后面会细说。启动项目之前先确认端口没被占用# Linux/macOS lsof -i :3000 # Windows netstat -ano | findstr :3000端口被占用是启动失败最常见的原因没有之一。养成启动前先查端口的习惯能省掉很多“为什么起不来”的困惑。4. 流式输出与状态同步React 前端接 agent 最容易翻车的地方4.1 SSE 还是 WebSocket先搞清楚你的场景需要哪个热词里react sse/websocket 轮询文件变化这个组合非常精准说明很多人在这里纠结。我的结论很明确agent 的流式输出用 SSE需要双向实时交互的场景才用 WebSocket。为什么因为 agent 的输出本质上是服务器单向推给浏览器的。用户发一条消息服务器开始吐 token浏览器只管接收和渲染。这种单向推送SSE 是最合适的它基于普通 HTTP实现简单浏览器原生支持EventSource断线还能自动重连。WebSocket 是双向的能力更强但你要自己处理心跳、重连、消息分帧复杂度高一个量级。那什么时候该用 WebSocket当你的 agent 需要在前端运行过程中被“打断”或者“注入新指令”的时候。比如用户看到 agent 跑偏了想中途喊停或者补充一句。这种双向交互SSE 做不了得上 WebSocket。// SSE 客户端接收示例 const eventSource new EventSource(/api/agent/stream?inputxxx); eventSource.onmessage (event) { const data JSON.parse(event.data); if (data.type chunk) { dispatch({ type: APPEND_CHUNK, chunk: data.content }); } else if (data.type done) { dispatch({ type: SET_STATUS, payload: false }); eventSource.close(); } }; eventSource.onerror () { dispatch({ type: SET_STATUS, payload: false }); eventSource.close(); };这里有个细节EventSource只支持 GET 请求如果你的输入很长URL 长度可能超限。解决办法是把输入先 POST 到一个接口存起来拿到一个 id再用EventSource带着 id 去订阅流。这个模式在长文本场景下几乎是必须的。4.2 流式渲染的性能陷阱为什么你的界面越跑越卡流式输出最爽的是看着文字一个个蹦出来最坑的是跑了几十条消息之后界面开始卡。原因通常有两个每条 chunk 都触发一次全量重渲染以及消息列表没有虚拟化。第一个问题的解法我前面提过用useReducer只更新最后一条消息。但还有一个更隐蔽的坑如果你在onmessage里直接setState而 chunk 来得非常快比如每秒几十个React 的批处理可能跟不上导致渲染队列积压。解法是加一个缓冲比如每 50 毫秒合并一次 chunk 再更新状态。// 带缓冲的 chunk 合并 let buffer ; let timer null; function handleChunk(chunk) { buffer chunk; if (!timer) { timer setTimeout(() { dispatch({ type: APPEND_CHUNK, chunk: buffer }); buffer ; timer null; }, 50); } }第二个问题是消息列表长了之后的渲染成本。如果不用虚拟化几百条消息的 DOM 节点会让浏览器很吃力。react-window或者react-virtuoso这类库能解决但要注意虚拟化列表里做流式更新会有点麻烦因为正在更新的那条消息可能在可视区外。我的做法是正在流式输出的消息不放进虚拟列表单独渲染在底部输出完成后再“归档”进虚拟列表。4.3 状态同步前端显示的和后端实际跑的要一致agent 项目里最让人抓狂的 bug是前端显示“正在思考”后端其实早就跑完了或者前端显示“已完成”后端还在调工具。这类问题的根源是状态源不唯一。我的原则是后端是唯一的状态源前端只做展示。后端每进入一个阶段就通过流推一个状态事件给前端。前端不自己推断状态只根据收到的事件更新 UI。这样即使网络有延迟前端显示的状态也一定对应后端的真实状态。具体到实现我会定义一套事件类型事件类型含义前端动作agent_startagent 开始运行显示运行中禁用输入thinking模型正在生成显示思考指示器tool_call准备调用工具显示工具名和参数tool_result工具返回显示工具结果摘要chunk文本片段追加到当前消息done运行结束启用输入归档消息error出错显示错误启用输入这套事件驱动的方式比前端自己猜状态可靠得多。而且调试的时候你只要看事件流就能还原整个 agent 的运行过程非常直观。5. 踩坑实录paperclip 这类项目最容易埋雷的五个地方5.1 循环不终止agent 为什么一直在调同一个工具这是我见过最多的坑。agent 调了一个工具拿到结果又调同一个工具参数几乎一样来回好几次。根本原因通常是工具返回的结果里包含了让模型误以为任务没完成的信息。举个例子你有一个“查询订单状态”的工具返回{status: pending}。模型看到 pending觉得还没完成就再查一次还是 pending再查……死循环。解法是在工具描述里明确告诉模型pending 是一个有效状态查到 pending 就可以结束了。或者在系统提示词里加一句“如果工具返回的状态是终态不要再重复调用”。另一个原因是工具返回了错误但格式不对。比如工具抛异常你的代码直接把异常堆栈塞回给模型模型看不懂就反复重试。正确做法是把错误包装成模型能理解的结构化信息比如{error: true, message: 订单号不存在请检查后重试}。5.2 上下文爆炸跑到第五轮就超限了agent 循环每跑一轮上下文就长一截。工具返回的结果如果很长比如查了一篇文章的全文几轮下来就把上下文塞满了。表现就是模型开始胡言乱语或者直接报 context length exceeded。我的处理策略分三层。第一层是工具结果截断超过一定长度的结果只保留前 N 个字符加省略号。第二层是历史消息摘要当消息数超过阈值把最早的一批消息交给模型总结成一段话替换掉原文。第三层是硬性轮次限制前面说的maxTurns到了就停。// 简单的工具结果截断 function truncateResult(result, maxLen 2000) { const str typeof result string ? result : JSON.stringify(result); if (str.length maxLen) return str; return str.slice(0, maxLen) \n...[结果过长已截断原长度 ${str.length}]; }截断的时候一定要告诉模型“这里被截断了”否则模型会以为结果就这么多做出错误判断。5.3 本地模型的工具调用能力参差不齐热词里开源模型、开源模型质变、ollama webui说明很多人用本地模型跑。这里有个残酷的现实不是所有本地模型都支持工具调用支持的那些格式也各不相同。有的用 JSON有的用特定标记有的干脆不支持。如果你用本地模型跑 paperclip先确认模型是否支持 function calling。不支持的话你得用提示词工程模拟工具调用——让模型输出特定格式的文本你再解析。这种方式稳定性差很多但总比不能用强。// 提示词模拟工具调用的解析 function parseToolCall(text) { const match text.match(/tool_call\s*(\{[\s\S]*?\})\s*\/tool_call/); if (!match) return null; try { return JSON.parse(match[1]); } catch { return null; } }用这种方式的时候系统提示词里必须非常明确地规定输出格式并且给几个例子。模型对格式的遵循程度直接决定这套方案能不能用。5.4 前端白屏React Native 和 Web 都可能遇到热词里react native 启动白屏这个坑在 Web 端同样存在。paperclip 的前端如果启动后白屏排查顺序是这样的先看浏览器控制台有没有报错再看 Network 面板里静态资源有没有加载成功最后看是不是路由配置问题。最常见的白屏原因是构建产物路径不对。比如你用 Vite 构建base配置默认是/但如果你把产物放在子路径下部署资源就加载不到。改成base: ./通常能解决。另一个原因是环境变量没注入。前端代码里用了import.meta.env.VITE_XXX但.env文件里没定义构建时不会报错运行时才白屏。养成习惯所有前端用到的环境变量都在.env.example里列出来部署时对照检查。5.5 依赖版本漂移昨天能跑今天报错这个坑最阴险。你什么都没改第二天npm install之后项目跑不起来了。原因是某个依赖发了新版本而你的package.json里用的是^范围自动升级到了不兼容的版本。解法是提交 lock 文件并且在 CI 里用npm ci而不是npm install。npm ci会严格按照 lock 文件安装不会自动升级。如果你在团队里协作lock 文件冲突了不要随便删了重装那会把别人的版本锁定也一起丢掉。提示遇到“昨天能跑今天报错”的情况第一件事是git diff package-lock.json看看哪个依赖的版本变了。十有八九问题就出在那里。6. 从能跑到好用paperclip 的进阶优化方向6.1 工具注册的插件化设计一个 agent 项目能不能长大关键看工具好不好加。如果每加一个工具都要改核心循环的代码那这个项目走不远。好的设计是工具注册表每个工具是一个独立模块导出名称、描述、参数 schema 和执行函数核心循环只负责遍历注册表。// 工具注册表示例 const toolRegistry new Map(); function registerTool(tool) { toolRegistry.set(tool.name, tool); } registerTool({ name: get_weather, description: 查询指定城市的天气, parameters: { type: object, properties: { city: { type: string, description: 城市名 } }, required: [city], }, async execute({ city }) { return await fetchWeather(city); }, });这样加工具就是加一个文件核心循环一行不用动。而且工具的 schema 可以直接转成模型需要的格式不用手写两遍。6.2 可观测性agent 跑一次到底发生了什么agent 的黑盒感是它最难调试的地方。你只看到输入和输出中间发生了什么全靠猜。所以可观测性不是锦上添花是必需品。至少要记录每次模型调用的耗时和 token 数、每次工具调用的名称参数结果耗时、整个任务的轮次和总耗时。这些数据可以打到日志里也可以存到数据库里前端用一个简单的面板展示。热词里react 图表、react uplot k线图在这里就派上用场了——把每次工具调用的耗时画成柱状图一眼就能看出哪个工具是瓶颈。6.3 错误恢复让 agent 自己从失败中爬起来一个健壮的 agent 不应该因为一次工具调用失败就整个崩掉。我前面说的“把错误塞回上下文”是最基础的一层。更进一步可以给工具调用加重试机制同一个工具失败后让模型换参数重试最多重试两次。两次都失败再把这个工具标记为不可用让模型换别的路径。还有一种情况是模型本身返回了格式错误的内容比如该返回 JSON 却返回了一段散文。这时候不要直接报错而是把格式要求再强调一遍让模型重新生成。这个“纠错重试”的逻辑能显著提升 agent 的成功率。6.4 部署从本地跑通到给别人用本地跑通和部署给别人用中间隔着好几道坎。第一道是进程管理别用node index.js裸跑用pm2或者systemd守护进程崩了能自动重启。第二道是反向代理SSE 长连接需要代理配置里关掉缓冲否则流式输出会被攒成一坨一次性发出来。Nginx 里要加proxy_buffering off;和proxy_cache off;。第三道是资源限制。agent 跑起来可能吃不少内存尤其是本地模型。给进程设个内存上限超了就重启比整个机器卡死强。第四道是日志轮转agent 的日志量不小不轮转的话磁盘很快就满了。# Nginx 里 SSE 的关键配置 location /api/agent/stream { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Connection ; proxy_buffering off; proxy_cache off; chunked_transfer_encoding off; }这几行配置我踩过坑不加的话前端会等很久才一次性收到所有内容流式的意义就没了。7. 关于 paperclip 这个名字我的一点个人理解折腾完这一圈我回头再看“paperclip”这个名字觉得它起得挺妙。回形针的价值不在于它本身多复杂而在于它能把散落的纸张拢在一起让它们变成一个整体。一个 AI agent 框架的价值也一样——模型、工具、前端、状态这些东西单独看都不新鲜难的是把它们夹在一起让它们协同工作还不散架。我在实际做这类项目的时候最大的体会是别一上来就追求功能全。先把“用户输入 → 模型输出 → 前端渲染”这条最短路径跑通哪怕只有一个工具、只支持一种模型。跑通之后再一个一个加工具、加状态、加错误处理。我见过太多人一开始就设计了一套复杂的插件系统和多模型适配层结果核心循环还没跑通就放弃了。另外一个体会是关于本地模型的。如果你用本地模型跑 agent对它的工具调用能力要有合理预期。7B 级别的模型简单工具调用勉强能用复杂一点的多步任务就容易跑偏。这不是框架的问题是模型能力的问题。想跑复杂任务要么上更大的模型要么把任务拆得更细让每一步都足够简单。最后说一个很实际的小技巧调试 agent 的时候把每一轮的完整消息列表打到日志里包括系统提示词、用户输入、模型输出、工具调用、工具结果。出问题的时候你把这串日志从头到尾读一遍八成能自己找到原因。agent 的 bug 很少是玄学基本都是某一轮的消息内容让模型产生了误解。能看到完整的消息流问题就解决了一半。
返回列表