1. 这不是调参,也不是炼丹:一个前端开发者第一次接触 Agent 时的真实困惑
“Agent 开发不是模型训练”——这句话我第一次在某次内部技术分享会上听到时,下意识点了两下鼠标,想翻回上一页确认是不是 PPT 写错了。当时我刚用 Llama.cpp 在本地跑通了一个 RAG 小 demo,正兴奋地给同事演示“我的 AI 应用上线了”,结果被一句轻描淡写的“你这还没进 Agent 的门”直接按在了门口。
后来我才明白,这句话不是打击,而是路标。它划清了一条被大量初学者反复踩坑的分水岭:一边是模型侧的权重、梯度、LoRA、QLoRA、数据清洗、SFT、DPO;另一边是系统侧的规划、工具调用、状态管理、记忆持久化、错误恢复、用户意图对齐、多步任务拆解与重试逻辑。前者像在实验室里培育一株新菌种,后者则是在真实商场里开一家能持续接单、处理退货、应对差评、还能记住老顾客口味的智能门店。
我见过太多前端同事——包括我自己——带着 React + TypeScript 的肌肉记忆冲进 AI 工程领域,第一反应就是:“我要微调一个模型!”“我要换更大的 base 模型!”“我要加更多训练数据!”结果花两周配好 DeepSpeed 环境,跑完三轮 loss 下降,最后发现:用户根本不需要你“更懂语法”,他们要的是“帮我把钉钉会议纪要转成可执行的 OKR 表格,并自动同步到飞书多维表格”。
这就是标题里那个沉甸甸的“不是”的全部分量。它不是否定模型价值,而是强调:Agent 的核心竞争力,90% 不在模型本身,而在模型之上的那一层“操作系统”。它要调度工具(API)、管理上下文(token 有限但任务无限)、维持状态(用户说“再查下刚才那家公司的融资轮次”,你得知道“刚才那家”是谁)、处理失败(天气 API 超时了,是重试?降级?还是换城市?),还要把整个过程包装成自然语言对话流,而不是一堆 JSON 响应体。
所以这篇内容,专为那些已经会写组件、会调接口、会处理 loading 和 error 状态、但第一次听说 “ReAct”、“Tool Calling”、“Memory Buffer” 就开始头皮发麻的前端人而写。它不讲如何从零训练 Qwen,也不教你怎么部署 vLLM,它只回答三个问题:
- Agent 到底在解决什么问题?(不是“让 AI 更聪明”,而是“让 AI 更可靠、更可控、更像一个能协作的同事”)
- 作为前端,你手上已有的技能树,哪些可以直接复用?哪些必须重构认知?(比如 useEffect 不是废了,而是升级成了“状态机触发器”)
- 从今天下午三点开始,不装 CUDA、不碰 GPU,纯用浏览器和 Vercel,你能跑出第一个真正意义上的 Agent 吗?(答案是:能,而且只需要 23 行核心代码)
这不是一篇“AI 入门指南”,而是一份“前端转型 Agent 工程师”的认知校准说明书。如果你正卡在“好像懂了,但又不知道下一步该敲哪行代码”的阶段,那接下来的内容,就是为你写的。
2. 核心设计思路拆解:为什么 Agent 架构天然适配前端思维?
2.1 把 Agent 想成一个“增强版的 React 组件树”
我们先扔掉所有 AI 黑话,回到最熟悉的场景:你正在开发一个「智能客服弹窗」。用户输入“我的订单 20240517-8821 物流停更了”,传统做法是写一个 if-else 分支,匹配关键词“物流”+“停更”,然后调用getLogisticsStatus(orderId)接口,再把返回的 JSON 映射成 UI。逻辑清晰,但扩展性差——加一条“查退款进度”,就得再加一个分支、一个接口、一套映射。
而一个基础 Agent 的工作流,本质上就是把这个过程显式声明化 + 可插拔化:
// 伪代码:一个极简 Agent 的“组件化”表达 const agent = new Agent({ // 相当于 useState({ status: 'idle', orderId: '' }) memory: new ConversationMemory(), // 相当于 useEffect(() => { ... }, [userInput]) planner: new ReActPlanner({ tools: [ { name: 'getLogisticsStatus', fn: api.getLogisticsStatus }, { name: 'getRefundStatus', fn: api.getRefundStatus }, { name: 'contactSupport', fn: api.contactSupport } ] }), // 相当于 return <div>{renderResponse()}</div> renderer: (step) => { if (step.type === 'tool_call') { return <LoadingSpinner tool={step.toolName} />; } if (step.type === 'tool_result') { return <LogisticsCard data={step.result} />; } } });看到没?memory是状态管理,planner是副作用逻辑(但它不是简单执行,而是先“思考”要不要调工具、调哪个、传什么参数),renderer是视图层。这整套范式,和你在写一个带复杂异步状态的 React 组件时的思考路径,高度同构。区别只在于:React 的 effect 是你写的确定性逻辑,而 Agent 的 planner 是由 LLM 驱动的、基于自然语言推理的“动态 effect 调度器”。
提示:很多前端同学卡在第一步,是因为潜意识里把 LLM 当成了“需要被驯服的野马”,总想用 prompt engineering 去“控制”它输出固定格式。但 Agent 思维的第一课是:把 LLM 当成一个不可靠但极其灵活的协作者,你的工作是设计一套鲁棒的协作协议(protocol),而不是给它写操作手册。就像你不会要求后端同事每次返回 JSON 都严格按你定义的字段顺序排列,而是定义好 OpenAPI Schema,然后用 Axios + Zod 做校验和转换。
2.2 “不是模型训练”的底层逻辑:算力与心智模型的错位
为什么标题斩钉截铁地说“不是模型训练”?我们来算一笔现实账。
假设你是一个独立开发者,手头只有一台 M2 MacBook Pro(16GB RAM)。你想做一个“帮用户分析周报 PDF 并生成下周计划”的 Agent。如果走模型训练路线,你至少面临三座大山:
- 数据墙:你需要几百份标注好的“PDF → 周报摘要 → 下周计划”三元组,还得覆盖不同行业、不同职级、不同写作风格。去哪找?怎么标注?谁来 QA?
- 算力墙:哪怕只做 100 个样本的 LoRA 微调,Qwen2-1.5B 在 CPU 上训一轮也要 17 小时(实测),且 loss 曲线飘得像心电图。
- 验证墙:训完之后,你如何证明它比直接用原生 Qwen2 + system prompt 效果更好?AB 测试怎么做?指标是什么?BLEU?ROUGE?还是人工看 50 份生成结果打分?
而 Agent 路线呢?你只需要:
- 找一个开源 PDF 解析库(如
pdf-parse或unstructured); - 接入一个现成的大模型 API(如 OpenRouter 上的 claude-3-haiku,$0.25/百万 tokens);
- 写一个简单的工具函数:
async function extractWeeklyReport(pdfBytes: Uint8Array) { ... }; - 设计一个 system prompt,明确告诉模型:“你是一个周报分析师助手。用户会上传 PDF,你先调用 extractWeeklyReport 工具解析内容,再基于解析结果,用中文生成结构化下周计划,包含【关键成果】【待办事项】【风险提示】三个部分。”
整个过程,你没碰过一行训练代码,没下载过一个.bin权重文件,但交付物的功能边界、可控性和可解释性,反而远超一个黑盒微调模型。因为所有“不可靠”的环节(PDF 解析失败、模型理解偏差、网络超时)都被显式暴露在工具调用链中,你可以针对性加重试、降级、兜底文案。
这就是“不是模型训练”的本质:它把问题域的不确定性,从“模型内部的隐式概率分布”,转移到了“系统外部的显式状态流转”。前者需要 PhD 级别的统计学功底和算力,后者需要的是扎实的工程抽象能力——而这,恰恰是前端工程师十年如一日锤炼的核心能力。
2.3 前端技能的“平移红利”:哪些能力可直接复用?
别急着学 LangChain。先盘点你已有的武器库,它们在 Agent 开发中不是过时了,而是升级了定位:
| 你已掌握的技能 | 在 Agent 开发中的新角色 | 实操体现举例 |
|---|---|---|
| HTTP 请求与错误处理 | 成为“工具(Tool)”的基石。每个 API 调用就是一个 Tool 定义。 | fetch('/api/logistics', { orderId })→{ name: 'getLogistics', parameters: { orderId } } |
| 状态管理(useState / Zustand) | 直接对应 Agent 的 Memory 层。ConversationMemory、SummaryMemory 本质就是带序列化/压缩的 state。 | 用户说“上一个问题的答案是什么?”,Memory 必须能准确召回前 5 轮上下文。 |
| 组件化与 Props 传递 | Agent 的 Planner、Executor、Renderer 可视为高阶组件,通过 config props 注入行为。 | 传入maxSteps: 5控制最大思考步数,传入timeoutMs: 10000控制单步超时。 |
| 表单验证与用户引导 | System Prompt 就是给 LLM 的“表单规则”。你写的zodschema,和给 LLM 的 JSON mode 提示,逻辑同源。 | 要求模型必须返回{"action": "search", "query": "string"},否则重试。 |
| 性能优化(Code Splitting) | Agent 的“工具懒加载”。不是所有 Tool 都需初始化,按需 import() 加载可大幅降低首屏体积。 | 只有当用户提到“股票”时,才动态 import('./tools/stock-tool.ts')。 |
最关键的平移点,是对“用户意图”的敏感度。前端天天和产品经理、UI 设计师、测试同学打交道,早就练就了从模糊需求(“这个按钮要点起来有反馈”)精准翻译成确定性实现(button:hover { opacity: 0.8; }+onMouseEnter事件)的能力。而 Agent 开发中,system prompt 的撰写、tool description 的措辞、failure fallback 的文案,全都是在做同一件事:把人类模糊的协作期望,翻译成机器可执行、可验证、可调试的协议。
所以,放下“我得先学 PyTorch”的焦虑。你缺的不是新知识,而是把旧知识放在新语境下重新组装的勇气。
3. 核心细节解析与实操要点:从零搭建一个“会议纪要助手” Agent
3.1 为什么选“会议纪要助手”作为第一个项目?
因为它完美覆盖了 Agent 的四大核心能力,且零 GPU、零训练、纯前端可跑:
- 多模态输入处理:用户上传
.docx或.pdf文件(前端用file-reader解析); - 工具调用(Tool Calling):调用文档解析 API(或本地库)提取文本;
- 规划与推理(Planning):LLM 判断“这是会议记录吗?如果是,关键人物、结论、待办事项分别是什么?”;
- 结构化输出(Structured Output):生成标准 Markdown 表格,含责任人、截止时间、状态列。
更重要的是,它的失败模式非常典型:PDF 解析乱码、模型漏掉某个待办项、用户上传了扫描件图片(非文本 PDF)。这些“坑”,正是你建立 Agent 工程直觉的最佳教材。
3.2 关键环节一:文档解析——别碰 OCR,先用“文本优先”策略
很多新手一上来就想集成 Tesseract 或 PaddleOCR,结果在 Mac 上编译失败三次,还没开始写 Agent 逻辑。其实 80% 的会议纪要文件,是 Word 或可复制文本的 PDF。我们的策略是:分层降级,文本优先。
// utils/document-parser.ts export async function parseDocument(file: File): Promise<string> { const arrayBuffer = await file.arrayBuffer(); const bytes = new Uint8Array(arrayBuffer); // Step 1: 尝试纯文本提取(最快,成功率最高) if (file.type === 'text/plain') { return new TextDecoder().decode(bytes); } if (file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') { // 使用 mammoth.js(纯 JS,无依赖) const result = await mammoth.convertToHtml({ arrayBuffer }); return result.value; } if (file.type === 'application/pdf') { // Step 2: 尝试 pdfjs-dist 提取文本(支持大部分可选中文 PDF) try { const pdf = await pdfjsLib.getDocument(arrayBuffer).promise; let fullText = ''; for (let i = 1; i <= pdf.numPages; i++) { const page = await pdf.getPage(i); const textContent = await page.getTextContent(); const strings = textContent.items.map((item: any) => item.str); fullText += strings.join(' ') + '\n\n'; } // 如果提取文本长度 > 200 字,认为是有效文本 PDF if (fullText.trim().length > 200) return fullText; } catch (e) { console.warn('PDF text extraction failed, falling back to OCR'); } } // Step 3: 最后才考虑 OCR(此时可提示用户“检测到扫描件,需稍等”) throw new Error('Unsupported file type or scanned PDF. Please upload text-based document.'); }注意:这里没有写 OCR 实现,因为对第一个项目而言,明确拒绝不支持的输入,比强行支持但效果差,更能建立用户信任。你可以后续用
tesseract.js做降级,但第一天,就让它报错。这是 Agent 设计的第一课:优雅降级(graceful degradation)比硬扛(brute force)更重要。
3.3 关键环节二:Tool Calling 协议——用 JSON Schema 定义你的“API 规范”
LLM 不是神,它是需要被“约束”的。我们不用复杂的 LangChain Tool 类,而用最朴素的 JSON Schema:
// types/tool-schema.ts export const MEETING_SUMMARY_TOOL_SCHEMA = { name: "extractMeetingSummary", description: "从会议纪要文本中提取结构化信息,包括关键结论、待办事项(含负责人和截止时间)、讨论议题。仅当输入明确是会议记录时调用。", parameters: { type: "object", properties: { rawText: { type: "string", description: "完整的会议纪要原始文本,可能包含时间、地点、参会人、发言记录等杂信息" } }, required: ["rawText"] } } as const; // 生成符合 OpenAI Function Calling 格式的工具定义 export function toOpenAITool(toolSchema: typeof MEETING_SUMMARY_TOOL_SCHEMA) { return { type: "function", function: { name: toolSchema.name, description: toolSchema.description, parameters: toolSchema.parameters } }; }这个 schema 的每一个字段,都在向 LLM 传递明确信号:
name:不是随便起的,它会出现在最终的{"name": "extractMeetingSummary", ...}中,是执行路由的关键;description:不是写给人看的,是写给 LLM 的“上下文锚点”。你描述得越具体(“仅当输入明确是会议记录时调用”),LLM 越少乱调;parameters.required:强制 LLM 必须提供rawText,否则调用失败,触发重试逻辑。
实测下来,相比泛泛的"Extract summary from text",这种带业务语境的描述,能让 GPT-4-turbo 的 tool call 准确率从 68% 提升到 92%(基于 50 个真实会议纪要样本测试)。
3.4 关键环节三:Planner 的最小实现——ReAct 模式的三步心跳
Agent 的“大脑”不需要多复杂。一个最简 ReAct(Reasoning + Acting)循环,只需三步:
- Think:给 LLM 一段 system prompt + 当前 conversation history + 可用 tools 列表,让它输出一个 JSON,包含
thought(推理过程)、action(要调哪个 tool)、action_input(参数); - Act:执行
action对应的函数,拿到结果; - Observe:把
action_input和result一起喂回 LLM,让它继续思考下一步。
我们用一个 30 行的useAgent自定义 Hook 来实现:
// hooks/useAgent.ts export function useAgent() { const [messages, setMessages] = useState<ChatMessage[]>([]); const [isRunning, setIsRunning] = useState(false); const run = useCallback(async (input: string) => { setIsRunning(true); const newMessages = [...messages, { role: 'user', content: input }]; // Step 1: Ask LLM to plan const planResponse = await fetch('/api/agent-plan', { method: 'POST', body: JSON.stringify({ messages: newMessages, tools: [MEETING_SUMMARY_TOOL_SCHEMA] }) }); const plan = await planResponse.json(); // { thought: "...", action: "extractMeetingSummary", action_input: { rawText: "..." } } // Step 2: Execute the action let toolResult; if (plan.action === 'extractMeetingSummary') { toolResult = await extractMeetingSummary(plan.action_input.rawText); } // Step 3: Feed result back and get final answer const finalResponse = await fetch('/api/agent-final', { method: 'POST', body: JSON.stringify({ messages: [...newMessages, { role: 'assistant', content: JSON.stringify(plan) }], toolResult }) }); const finalAnswer = await finalResponse.json(); setMessages(prev => [...prev, { role: 'user', content: input }, { role: 'assistant', content: finalAnswer }]); setIsRunning(false); }, [messages]); return { messages, isRunning, run }; }实操心得:不要一开始就追求“一步到位生成最终答案”。把 Think-Act-Observe 拆成三个独立 HTTP 请求,是你获得完全控制权的唯一方式。它让你能:
- 在
agent-plan接口里加日志,看 LLM 到底“想”了什么;- 在
agent-final接口里加重试逻辑(比如 toolResult 是空,就让 LLM 重试);- 在前端 UI 上显示
Thinking...、Calling extractMeetingSummary...、Formatting response...,极大提升用户耐心。
这就是前端最擅长的“loading 状态管理”,只是对象从fetch()变成了LLM + Tool。
4. 实操过程与核心环节实现:23 行代码跑通全流程
4.1 前端:一个可运行的完整页面(Vite + React)
我们抛弃所有框架,用最原始的 Vite 创建一个meeting-agent项目。核心文件只有三个:
src/App.tsx
import { useState, useRef, useEffect } from 'react'; import { useAgent } from './hooks/useAgent'; function App() { const { messages, isRunning, run } = useAgent(); const fileInputRef = useRef<HTMLInputElement>(null); const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { const file = e.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = async (e) => { const text = e.target?.result as string; // 直接把文本内容作为用户输入(跳过解析,简化第一步) run(`请分析以下会议纪要:\n${text}`); }; reader.readAsText(file); }; return ( <div className="p-4 max-w-4xl mx-auto"> <h1 className="text-2xl font-bold mb-4">会议纪要助手</h1> <div className="mb-4"> <label className="block mb-2">上传会议纪要(TXT/DOCX/PDF):</label> <input type="file" ref={fileInputRef} onChange={handleFileUpload} accept=".txt,.docx,.pdf" className="border p-2 rounded" /> </div> <div className="bg-gray-50 p-4 rounded-lg min-h-[200px]"> {messages.length === 0 ? ( <p className="text-gray-500">上传文件开始分析...</p> ) : ( messages.map((msg, i) => ( <div key={i} className={`mb-3 ${msg.role === 'user' ? 'text-right' : 'text-left'}`}> <strong>{msg.role === 'user' ? '你:' : '助手:'}</strong> <div className="whitespace-pre-wrap">{msg.content}</div> </div> )) )} {isRunning && <div className="text-blue-600">助手正在思考中...</div>} </div> </div> ); } export default App;src/hooks/useAgent.ts(精简版,23 行核心逻辑)
import { useState, useCallback } from 'react'; export function useAgent() { const [messages, setMessages] = useState<{role: 'user'|'assistant', content: string}[]>([]); const [isRunning, setIsRunning] = useState(false); const run = useCallback(async (input: string) => { setIsRunning(true); const newMsgs = [...messages, { role: 'user', content: input }]; try { // 1. 发送请求到后端 Agent 接口(模拟) const res = await fetch('/api/agent', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messages: newMsgs, input }) }); const data = await res.json(); // 2. 更新消息(后端已返回完整 assistant 消息) setMessages(prev => [...prev, { role: 'user', content: input }, { role: 'assistant', content: data.answer }]); } catch (err) { setMessages(prev => [...prev, { role: 'user', content: input }, { role: 'assistant', content: '分析失败,请重试。' }]); } finally { setIsRunning(false); } }, [messages]); return { messages, isRunning, run }; }src/server/api/agent.ts(Vercel Edge Function,/api/agent/route.ts)
// Vercel Edge Function - 无需 Node.js 运行时 export const runtime = 'edge'; export async function POST(req: Request) { const { messages, input } = await req.json(); // 模拟 LLM 调用(实际替换为 OpenAI / Anthropic API) const mockResponse = `## 会议结论\n- 确定 Q3 上线新风控模型\n- 同意增加 2 名算法工程师编制\n\n## 待办事项\n| 事项 | 负责人 | 截止时间 |\n|------|--------|----------|\n| 输出风控模型 PRD | 张三 | 2024-06-15 |\n| 启动招聘流程 | 李四 | 2024-06-20 |\n\n## 风险提示\n- 外部数据源接口稳定性待验证`; return Response.json({ answer: mockResponse }); }提示:这 23 行(前端 Hook + 后端 Route)就是你第一个可运行 Agent 的全部骨架。它不炫技,但每一步都可调试、可监控、可替换。当你把
mockResponse换成真实的 OpenAI API 调用,把readAsText换成前面的parseDocument,你就拥有了一个生产就绪的 MVP。
4.2 后端:从 Mock 到真实 —— 三步接入 OpenAI
现在,我们把/api/agent从 mock 升级为真实 LLM 调用。核心就三步:
Step 1:准备 Tools 列表
// app/api/agent/route.ts const TOOLS = [ { type: "function", function: { name: "extractMeetingSummary", description: "从会议纪要文本中提取结构化信息...", parameters: { /* 同前文 schema */ } } } ];Step 2:构造 Messages(关键!System Prompt 决定一切)
const systemPrompt = `你是一个专业的会议纪要分析师。你的任务是: 1. 严格基于用户提供的原始会议文本进行分析,不添加任何外部知识; 2. 输出必须包含三个一级标题:## 会议结论、## 待办事项、## 风险提示; 3. 待办事项必须是 Markdown 表格,列名为:事项、负责人、截止时间; 4. 如果文本中未提及负责人或截止时间,写“待定”; 5. 如果无法识别为会议纪要,直接回复:“未检测到有效会议纪要,请检查文件内容。”`; const messages = [ { role: "system", content: systemPrompt }, ...messages.map(m => ({ role: m.role as 'user'|'assistant', content: m.content })) ];Step 3:调用 OpenAI 并处理 Tool Call
const response = await openai.chat.completions.create({ model: "gpt-4-turbo", messages, tools: TOOLS, tool_choice: "auto" // 让模型自己决定是否调用工具 }); const message = response.choices[0].message; if (message.tool_calls?.length) { // 模型决定调用工具 const toolCall = message.tool_calls[0]; if (toolCall.function.name === "extractMeetingSummary") { const args = JSON.parse(toolCall.function.arguments); const result = await extractMeetingSummary(args.rawText); // 第二次调用:把工具结果喂回去,生成最终回答 const finalResponse = await openai.chat.completions.create({ model: "gpt-4-turbo", messages: [ ...messages, message, { role: "tool", tool_call_id: toolCall.id, content: JSON.stringify(result) } ], // 此次不开放 tool calling,强制生成最终文本 tools: [] }); return Response.json({ answer: finalResponse.choices[0].message.content }); } } // 模型没调用工具,直接返回 return Response.json({ answer: message.content });实操心得:永远不要相信模型第一次就给出完美答案。真正的 Agent 工程,80% 的精力都在处理
tool_calls为空、arguments解析失败、tool执行超时、result格式异常这些“边角情况”。我在某次压测中发现,GPT-4-turbo 在 12% 的请求中会返回tool_calls: [],即使文本明显是会议纪要。解决方案?加一层 fallback:如果tool_calls为空,就用gpt-3.5-turbo重试一次,成本几乎为零,但成功率提升到 99.2%。
4.3 部署:Vercel 一键上线,零配置
Vercel 对 Edge Functions 的支持,让部署变得像提交 Git 一样简单:
git init && git add . && git commit -m "first agent"vercel(登录后选择项目,Vercel 自动识别app/api/为 Edge Function)- 几秒后,得到一个
https://meeting-agent-yourname.vercel.app的 URL
你甚至不需要配置域名、SSL、CDN。Vercel 自动处理全球边缘缓存、DDoS 防护、自动扩缩容。一个日活 1000 的会议助手,月账单通常低于 $5。
注意:Vercel Edge Functions 的执行时间上限是 30 秒,内存上限 1GB。这对 PDF 解析(尤其是大文件)是个挑战。解决方案是:把耗时操作(如 OCR、大 PDF 解析)卸载到 Worker 或 Queue。但对第一个项目,我们坚持“够用就好”——限制上传文件大小为 5MB,用
pdfjs-dist的流式解析,95% 的会议纪要都能在 3 秒内完成。
5. 常见问题与排查技巧实录:前端人踩过的 7 个真实深坑
5.1 问题 1:LLM 死活不调用 Tool,一直胡说八道
现象:你定义了extractMeetingSummary工具,system prompt 也写了“仅当是会议纪要时调用”,但模型就是返回一段自由发挥的总结,tool_calls数组永远为空。
排查路径:
- ✅ 检查
tools数组是否真的传给了openai.chat.completions.create()(console.log 一下); - ✅ 检查
tool_choice参数:"none"会禁用,"required"会强制调用(即使不合理),"auto"才是正确选择; - ✅最关键:检查
system prompt里是否包含了extractMeetingSummary这个名字。很多新手写:“你有工具可以分析会议纪要”,但没写工具名。LLM 不认识你,它只认字符串匹配。
终极解法:在 system prompt 末尾加一句硬约束:
“你必须严格遵守以下协议:如果用户输入是会议纪要,你只能调用 extractMeetingSummary 工具;除此之外,禁止调用任何其他工具,禁止自行生成结论。”
实测,这句加上后,tool call 触发率从 41% 跃升至 89%。
5.2 问题 2:Tool 执行成功,但 LLM 返回的content是空字符串
现象:response.choices[0].message.content是"",但tool_calls存在且执行成功,导致前端显示一片空白。
原因:这是 OpenAI 的设计特性。当模型决定调用 tool 时,它会把content设为空,把所有“思考”都放进tool_calls。它默认你接下来会做第二次调用。
解决方案:永远不要直接渲染message.content。正确的渲染逻辑是:
if (message.tool_calls?.length) { // 渲染:正在调用 [tool.name]... // 然后等待第二次 API 调用返回的最终 content } else { // 渲染 message.content }提示:这个坑我踩了三次。第一次以为是 API bug,第二次以为是 prompt 写错,第三次才在 OpenAI 文档角落发现这句话:“When a tool is called, the model’s response will have an empty content field.” —— 记住,LLM 的
content字段,只在它“不调用工具,直接回答”时才有值。
5.3 问题 3:中文 PDF 解析全是乱码,或者只提取出几行字
现象:用户上传一个中文 Word 文档,解析后变成“????”;或者 PDF 只提取出页眉页脚,正文全无。
根因:pdfjs-dist默认使用 Latin 字体编码。中文 PDF 需要显式启用 CMap。
修复代码:
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.min.mjs'; import pdfjsWorker from 'pdfjs-dist/legacy/build/pdf.worker.entry.mjs'; // 必须设置 worker 和 cmaps pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorker; pdfjsLib.PDFJS.cMapUrl = 'https://cdn.jsdelivr.net/npm/pdfjs-dist@3.4.120/cmaps/'; pdfjsLib.PDFJS.cMapPacked = true;同时,在getPage()后,手动指定文本层字体:
const textContent = await page.getTextContent({ normalizeWhitespace: true });备选方案:如果仍不稳定,直接切到pdf-lib+text-extract组合,专攻文本 PDF,放弃扫描件支持——再次强调,明确边界,胜过勉强兼容。
5.4 问题 4:用户连续问两个问题,Agent “忘记”了第一个的上下文
现象:用户上传 A 文件,得到分析结果;接着上传 B 文件,Agent 却在分析 B 时,还引用了 A 文件里的“张三”、“风控模型”等名词。
原因:你把messages存在了前端useState,但没做隔离。每次run()都把历史messages全部传给后端,后端又原样塞进messages数组,导致上下文污染。
正解:Agent 的 Memory 必须有作用域。为每个文件会话生成唯一 ID:
// 前端 const sessionId = useRef(crypto.randomUUID()).current; run({ input, sessionId }); // 后端 const sessionKey = `agent:${sessionId}`; const history = await kv.get(sessionKey) || []; await kv.set(sessionKey, [...history, { role: 'user', content: input }, { role: 'assistant', content: answer }]);Vercel KV 或 Redis 就是你的useState升级版。没有它,就没有真正的状态管理。
5.5 问题 5:tool_call的arguments是 JSON 字符串,但JSON.parse()报错
现象:toolCall.function.arguments看起来是{"rawText":"..."},但JSON.parse()抛出SyntaxError: Unexpected token。