
1. 项目概述为什么前端工程师突然开始聊 Agent“Agent 开发不是模型训练”——这句话最近在技术群、前端沙龙和内部分享里被反复提起像一句暗号也像一道分水岭。我第一次听到它是在某次跨团队协作的站会上一位前端同事正解释为什么他没接下“用大模型优化搜索推荐”的需求而是建议后端同学先搭个轻量级 Agent 框架。当时会议室安静了两秒有人小声问“Agent不是得调 API 就完事了吗还要搭框架”——这恰恰就是标题想戳破的认知盲区。对绝大多数前端开发者来说“大模型”三个字长期等同于“调用 OpenAI 或国内某平台的 /chat/completions 接口”写个 prompt、拼个 message 数组、parse 一下 response再塞进 React 组件里渲染一套流程跑通任务就算交付。但现实很快打了脸用户反馈“回答太死板”“记不住上下文”“总在重复解释同一个概念”“没法主动追问澄清需求”……问题不在模型能力而在整个交互链路是单向、被动、无状态的——这根本不是 Agent只是个高级回声壁。标题里那个斩钉截铁的“不是”其实在划一条实操边界模型训练是数据科学家和算法工程师的战场而 Agent 开发是软件工程师尤其是前端的主阵地——它考的是系统设计能力、状态管理思维、异步流程编排功底以及对用户真实操作路径的深刻理解。我们不碰梯度下降但必须亲手设计记忆怎么存、工具怎么调、错误怎么降级、超时怎么兜底、UI 如何实时反映 Agent 的思考状态。这不是“用模型”而是“造一个会思考的数字同事”。这个认知转变直接决定了你投入时间的方向。如果你还在纠结要不要学 PyTorch、要不要配 A100 显卡、要不要啃《深度学习》花书那方向就偏了但如果你开始研究 Zustand 怎么持久化对话历史、SWR 如何优雅处理多轮 tool call 的 loading 状态、如何用 Web Workers 避免长思考阻塞主线程、甚至考虑用 IndexedDB 做本地缓存策略——恭喜你已经站在 Agent 开发的起跑线上。这篇文章就是给这样一群正在切换思维模式的前端同行写的实战手记不讲论文只聊代码、状态、错误和用户那一声真实的“啊它懂我了”。2. 核心思路拆解前端视角下的 Agent 架构到底长什么样2.1 把“智能体”翻译成前端能理解的组件模型别被“Agent”这个词唬住。在前端工程师的语境里一个最小可用的 Agent本质上就是一个带状态、可中断、能调外部服务、并持续与用户交互的增强型 React 组件。它的核心职责不是“生成答案”而是“管理一次智能对话的全生命周期”。我们来把它拆解成几个前端熟悉的模块State Manager状态中枢不是简单的 useState({ messages: [] })。它要同时维护当前对话 ID、用户原始输入、Agent 正在执行的步骤thinking / calling tool / waiting for result / generating final response、已调用的工具列表、各工具返回的结构化数据、最终回复草稿、错误堆栈、重试次数……这已经远超 UI 状态接近一个轻量级 Redux store。我见过最稳的方案是用 Zustand immer persist 插件把整个对话 session 序列化存到 localStorage关页面再打开Agent 还记得刚才在查天气。Orchestrator流程调度器这是 Agent 的“大脑皮层”。它接收用户新消息决定下一步动作是直接用 LLM 生成回复还是需要先调用天气 API调用后结果是否要喂给 LLM 做二次加工如果天气 API 超时是降级用缓存数据还是提示用户稍后再试这个逻辑不能写死在组件里必须抽成独立 hook比如 useAgentOrchestrator()里面封装 if-else 判断树和 retry 机制。关键点在于所有决策必须可追溯、可调试、可打日志。我在某次压测中发现30% 的失败源于 Orchestrator 在网络抖动时没有正确识别“部分成功”状态比如天气 API 返回了城市名但没返回温度导致流程卡死——后来加了一层状态机校验才解决。Tool Registry工具注册中心前端常误以为“调 API 就是调工具”其实不然。一个合格的 Tool必须包含三要素描述LLM 能理解的自然语言说明、参数 SchemaJSON Schema 格式让 LLM 知道该传什么、执行函数前端可调用的 async 函数。比如“查实时天气”这个 Tool描述是“获取指定城市当前温度、湿度、天气状况”Schema 定义 city 字段为 required string执行函数则封装了 fetch 调用和错误处理。重点来了Tool 的执行函数必须是纯前端可运行的。这意味着你要自己实现天气查询调公开 API而不是指望后端提供一个“/agent/tool/weather”接口——后者又把复杂度推回去了。我试过用 Vercel Edge Functions 做中间层结果发现延迟高、调试难最终全部收归前端直连反而更可控。UI Renderer响应式渲染器这才是前端最擅长的部分但要求更高。不能只渲染最终文字。用户需要看到 Agent “正在思考”显示 typing indicator、“正在调用天气服务”显示城市图标加载动画、“已获取数据正在整合”显示结构化卡片进度条。我用了一个 trick把 Agent 的每一步状态都映射到一个 React 组件比如 、 、 然后用一个统一的 动态渲染。好处是 UI 可测试、可复用、状态变更一目了然。提示千万别把 Agent 当成一个黑盒 Promise。它是一套有血有肉的状态机。你的第一个目标不是让它“答得准”而是让它“状态清、流程明、失败有退路”。2.2 为什么坚决不做模型训练前端的不可替代性在哪里这个问题常被误解。有人会说“可模型效果不好不训练怎么行”——这里存在一个根本错位前端开发的 Agent其价值不在于模型参数本身而在于模型能力与用户场景之间的“适配层”。就像当年 jQuery 不是发明了 DOM API而是让 DOM 操作变得可靠、跨浏览器、易组合Agent 框架也不是创造新模型而是让现有模型能力在具体业务中稳定、可预期、可调试地释放。举个真实例子某电商客服场景用户问“我上周买的蓝牙耳机还没发货能查下物流吗”。纯调 LLM 接口大概率得到两种结果一是模型瞎猜一个单号危险二是直接拒绝“我无法访问物流系统”。而一个合格的前端 Agent会这样做意图识别用极简规则关键词“发货”“物流”时间词“上周”触发“查订单”Tool参数提取从用户话中抽取出“蓝牙耳机”作为商品关键词结合用户登录态自动关联最近订单工具调用调用内部订单查询 API传入用户 ID 和商品关键词结果处理API 返回订单 ID 和物流单号Agent 再调用快递 100 查询接口生成回复把物流状态如“已揽件”和预计送达时间用自然语言组织避免暴露单号等敏感信息。整个过程模型只参与第 1 步意图识别和第 5 步语言润色核心逻辑全是前端编排的。模型是引擎前端是方向盘、刹车、仪表盘和导航仪。训练模型解决的是“引擎功率”而 Agent 开发解决的是“怎么安全、高效、舒适地开这辆车”。前者需要 GPU 和数据后者需要你对业务流程的熟稔、对异常的敬畏、对用户体验的抠门。所以前端工程师的不可替代性体现在三个硬核能力上领域建模能力能把模糊的用户需求“帮我找便宜的咖啡机”拆解成可执行的原子步骤查价格区间、过滤品牌、比对参数、生成对比摘要错误韧性设计当某个 Tool 失败如支付接口超时能优雅降级显示“支付中请稍候或点击此处手动跳转”而不是整个对话崩溃实时反馈工程让用户时刻感知系统状态消除等待焦虑。这点后端和算法工程师天然缺乏前端的“像素级”敏感度。注意当你开始为每个 Tool 编写详细的 TypeScript 类型定义、为每次状态变更添加 console.time 日志、为 UI 加载状态设计 3 种不同粒度的 skeleton你就已经走在正确的路上了。这些工作没有一行代码在训练模型但每一行都在构建真正可用的 Agent。3. 核心细节解析与实操要点从零搭建一个可运行的前端 Agent3.1 工具选型为什么是 React TypeScript Zustand TanStack Query选型不是炫技而是为解决 Agent 开发中的特定痛点。我拿自己落地的第一个生产级 Agent用于内部知识库问答为例说明每个技术选型背后的“不得不”。React18核心诉求是并发渲染Concurrent Rendering。Agent 的典型场景是用户发送问题 → Agent 开始思考可能耗时 200ms→ 同时发起多个 Tool 调用查文档、查会议纪要、查代码仓库→ 每个调用返回后UI 需要局部更新。React 18 的 useTransition 和 Suspense 能完美处理这种“思考中”和“部分结果已就绪”的混合状态。我试过用 Vue 3 的 suspense但其对异步状态的粒度控制不如 React 精细尤其在多个并行 Promise 的 loading 状态管理上容易混乱。TypeScriptAgent 的灵魂是类型即契约。LLM 的输出是文本但 Tool 的输入输出必须是强类型。比如“查会议纪要”Tool其参数 Schema 是{ date: string, topic?: string }返回类型是{ summary: string, decisions: string[], actionItems: { who: string, what: string, when: string }[] }。TS 能在编译期就捕获“调用时漏传 date”或“试图访问不存在的 actionItems[0].assignee”这类错误。更重要的是Zod Schema 可以和 TS 类型双向同步保证 LLM 解析的 JSON 结构和前端运行时类型 100% 一致——这是我踩过最多坑后总结的铁律。Zustand状态管理的终极目标是可序列化、可持久化、可热更新。Redux 太重Context 太脆弱。Zustand 的 create 函数返回一个 store其 state 可以轻松 JSON.stringify() 存 localStorage页面刷新后用 persist 插件一键恢复。关键点在于Agent 的 state 必须是纯对象不能包含函数或 Promise。所以我把所有 Tool 执行函数都放在 store 外部state 里只存调用参数、返回结果、错误信息。这样state 就是干净的、可预测的、可快照的。TanStack Queryv5专治Tool 调用的异步地狱。它原生支持自动去重同一参数的天气查询10 个组件同时触发Query 只发 1 次请求智能缓存查过“北京”天气30 秒内再次查直接返回缓存不发请求错误重试网络失败自动按指数退避重试数据预取用户输入“上海”时提前 prefetch “上海”天气提升体验。这些能力如果手写至少 500 行代码。而 Query 用useQuery({ queryKey: [weather, city], queryFn: () fetchWeather(city) })一行搞定。我甚至用它管理“LLM 流式响应”的 chunk 缓存让断网重连后能续播。实操心得不要试图用一个库解决所有问题。Zustand 管全局对话状态谁在说话、说了什么、在哪一步Query 管每个 Tool 的局部数据流天气数据、文档内容、代码片段。两者分工明确耦合度极低。3.2 关键环节如何设计一个可扩展的 Tool 注册与调用机制Tool 是 Agent 的“手脚”设计好坏直接决定扩展性。我摒弃了早期用 switch-case 匹配 Tool 名称的方案改用基于注册表的动态机制。核心代码只有 3 个部分第一步定义 Tool 接口// types/tool.ts export interface ToolDefinitionTParams, TResult { name: string; // LLM 调用时的名称如 search_knowledge description: string; // LLM 理解用途的自然语言描述 parameters: ZodZodObjectany; // Zod Schema定义参数结构 execute: (params: TParams, context: ToolContext) PromiseTResult; // 执行函数 } // ToolContext 提供通用能力如 logger、abortController export interface ToolContext { logger: Console; abortController: AbortController; }第二步创建注册中心单例// lib/tool-registry.ts import { create } from zustand; interface ToolRegistryState { tools: Mapstring, ToolDefinitionany, any; } export const useToolRegistry createToolRegistryState(() ({ tools: new Map(), })); // 注册函数通常在应用启动时调用 export const registerTool TParams, TResult( tool: ToolDefinitionTParams, TResult ) { useToolRegistry.setState((state) { state.tools.set(tool.name, tool); return state; }); };第三步Orchestrator 中的安全调用// hooks/use-agent-orchestrator.ts import { useToolRegistry } from /lib/tool-registry; import { z } from zod; export const useAgentOrchestrator () { const { tools } useToolRegistry(); const callTool async (toolName: string, rawParams: Recordstring, any) { const tool tools.get(toolName); if (!tool) throw new Error(Tool not found: ${toolName}); // 1. 参数校验用 Zod Schema 验证 rawParams try { const parsedParams tool.parameters.parse(rawParams); // 2. 创建上下文含 abortController 防止超时 const abortController new AbortController(); const context: ToolContext { logger: console, abortController, }; // 3. 执行并包裹超时 const timeoutId setTimeout(() abortController.abort(), 10000); const result await tool.execute(parsedParams, context); clearTimeout(timeoutId); return { success: true, result }; } catch (e) { // 4. 统一错误格式便于 UI 展示 return { success: false, error: e instanceof Error ? e.message : Unknown error }; } }; return { callTool }; };这个设计带来的好处是爆炸性的新增 Tool 零侵入只需写一个符合接口的对象调用registerTool()Orchestrator 自动识别LLM 友好description 和 parameters 直接喂给 LLM它就能学会何时调用、怎么调用调试友好所有 Tool 调用都经过统一入口可以集中打日志、监控耗时、统计成功率安全可控参数强校验杜绝了 LLM 乱传参数导致的 XSS 或越权访问。注意事项Tool 的 execute 函数里绝对不要直接操作 DOM 或修改全局状态。它应该是一个纯粹的数据获取函数返回结构化数据。UI 更新由 Orchestrator 触发状态变更再由 React 渲染。这是保持数据流单向、可预测的关键。3.3 状态管理实战如何用 Zustand 持久化一次完整的对话一个对话的 state远不止messages: []。我定义的完整对话状态如下精简版// types/conversation.ts export interface ConversationState { id: string; // UUID唯一标识一次对话 title: string; // 自动生成如“关于 React 18 并发渲染的讨论” createdAt: Date; updatedAt: Date; status: idle | thinking | calling-tool | generating | error; // 当前整体状态 currentStep: { type: llm-think | tool-call | tool-result | final-response; toolName?: string; toolParams?: Recordstring, any; toolResult?: any; llmPrompt?: string; } | null; messages: Array{ id: string; role: user | assistant | tool; content: string; timestamp: Date; metadata?: { toolCallId?: string; // 关联 tool call streaming?: boolean; // 是否流式 }; }; toolCalls: Array{ id: string; name: string; params: Recordstring, any; status: pending | success | error; result?: any; error?: string; startedAt: Date; finishedAt?: Date; }; error: string | null; retryCount: number; }用 Zustand 实现持久化关键在persist插件的配置// store/conversation-store.ts import { create } from zustand; import { persist, createJSONStorage } from zustand/middleware; // 仅持久化必要字段剔除函数、Promise 等不可序列化项 const cleanStateForPersist (state: ConversationState) ({ id: state.id, title: state.title, createdAt: state.createdAt.toISOString(), updatedAt: state.updatedAt.toISOString(), status: state.status, currentStep: state.currentStep, messages: state.messages.map(m ({ ...m, timestamp: m.timestamp.toISOString(), })), toolCalls: state.toolCalls.map(tc ({ ...tc, startedAt: tc.startedAt.toISOString(), finishedAt: tc.finishedAt?.toISOString(), })), error: state.error, retryCount: state.retryCount, }); export const useConversationStore createConversationState()( persist( (set, get) ({ // 初始化状态 id: crypto.randomUUID(), title: 新对话, createdAt: new Date(), updatedAt: new Date(), status: idle, currentStep: null, messages: [], toolCalls: [], error: null, retryCount: 0, // 更新方法 updateStatus: (status) set((state) { const now new Date(); return { ...state, status, updatedAt: now, currentStep: status idle ? null : state.currentStep, }; }), addMessage: (message) set((state) { const now new Date(); return { ...state, messages: [...state.messages, { ...message, timestamp: now }], updatedAt: now, }; }), // ... 其他方法 }), { name: conversation-storage, // localStorage key storage: createJSONStorage(() localStorage), // 仅保存清理后的状态 partialize: cleanStateForPersist, // 重置时的默认值 onRehydrateStorage: () (state) { if (state) { // 从字符串恢复 Date 对象 state.createdAt new Date(state.createdAt); state.updatedAt new Date(state.updatedAt); state.messages state.messages.map(m ({ ...m, timestamp: new Date(m.timestamp), })); } }, } ) );这个设计解决了三个致命问题页面刷新不丢上下文用户正在查天气刷新页面Agent 自动恢复到“calling-tool”状态并重试多标签页隔离每个对话有独立 IDlocalStorage 里存多个 key互不干扰状态可审计cleanStateForPersist 强制只存 JSON 安全字段避免意外序列化失败。实操心得在onRehydrateStorage里做 Date 恢复是必须的因为 JSON.stringify 会把 Date 变成字符串。另外partialize不仅是为了减小存储体积更是为了防止未来 state 加了不可序列化字段如函数导致整个 store 加载失败——这是一种防御性编程。4. 实操过程与核心环节实现一个完整对话的生命周期详解4.1 用户提问从输入框到 LLM Prompt 的完整旅程用户在输入框敲下“帮我查下今天北京的天气还有适合穿什么衣服”按下回车。这个看似简单的动作背后是前端 Agent 的第一道精密流水线。我们一步步拆解Step 1输入预处理Input Sanitization移除首尾空格、过滤控制字符\u0000-\u001F防止注入检查长度超过 2000 字截断并提示“内容过长请精简”关键一步添加系统指令前缀。这不是给用户看的而是告诉 LLM “你是谁、该做什么、有什么工具”。我的前缀模板是你是一个专业的助手正在与用户进行多轮对话。你拥有以下工具 - search_weather: 获取指定城市的实时天气和穿衣建议。参数{city: string, date: string (YYYY-MM-DD, 默认今天)} - search_knowledge: 在公司内部知识库中搜索相关信息。参数{query: string} 请严格按以下规则行动 1. 如果问题需要实时数据如天气、股价必须调用对应工具 2. 如果工具返回数据需整合后用自然语言回复避免直接粘贴 JSON 3. 如果不确定主动向用户澄清不要猜测。Step 2构造 Messages 数组const systemMessage { role: system, content: systemPrompt }; const userMessage { role: user, content: userInput }; // 从 store 读取历史消息最多取最后 10 条防 token 超限 const historyMessages useConversationStore.getState().messages.slice(-10); // 最终发送给 LLM 的数组 const messagesToSend [ systemMessage, ...historyMessages, userMessage ];Step 3流式请求与分块解析我用fetch发送 POST 请求Accept: text/event-stream并监听event: message。关键在于LLM 的流式响应不是一行一行的 JSON而是类似event: message data: {delta:{role:assistant,content:今天北京},finish_reason:null} event: message data: {delta:{content:天气晴朗气温},finish_reason:null} event: message data: {delta:{content:18度},finish_reason:null} event: message data: {delta:{content:适合穿衬衫和薄外套。},finish_reason:stop}前端需要用EventSource或fetch ReadableStream解析拼接delta.content形成完整回复当finish_reason为stop或length时结束流实时将每个delta.content推送到 Zustand store触发 UI 实时渲染。const controller new AbortController(); const response await fetch(/api/chat, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ messages: messagesToSend }), signal: controller.signal, }); const reader response.body?.getReader(); while (true) { const { done, value } await reader?.read() || { done: true, value: undefined }; if (done) break; const chunk new TextDecoder().decode(value); const lines chunk.split(\n).filter(line line.trim()); for (const line of lines) { if (line.startsWith(data: )) { try { const data JSON.parse(line.slice(6)); if (data.delta?.content) { // 更新 store 中的 assistant 消息 useConversationStore.getState().updateStreamingMessage(data.delta.content); } } catch (e) { console.warn(Parse SSE failed:, e); } } } }注意事项流式响应必须设置response.headers.set(X-Content-Type-Options, nosniff)防止 MIME 类型嗅探攻击AbortController 必须在组件卸载时调用controller.abort()否则内存泄漏。4.2 LLM 决策当模型说“需要调用工具”时前端如何接招LLM 的响应不是纯文本而是结构化的 Function Call 请求。主流模型如 OpenAI 的gpt-4-turbo、国内某平台的Qwen支持function_call模式返回类似{ role: assistant, content: null, function_call: { name: search_weather, arguments: {\city\: \北京\, \date\: \2024-05-20\} } }前端的处理流程是教科书级的严谨Step 1检测 function_callif (response.function_call) { const { name, arguments: argsStr } response.function_call; try { const args JSON.parse(argsStr); // 必须 parse不能直接 eval // 2. 参数校验用注册的 Zod Schema const tool useToolRegistry.getState().tools.get(name); if (!tool) throw new Error(Tool ${name} not registered); const validatedArgs tool.parameters.parse(args); // Zod 校验 // 3. 记录 tool call 到 store const toolCallId crypto.randomUUID(); useConversationStore.getState().addToolCall({ id: toolCallId, name, params: validatedArgs, status: pending, startedAt: new Date(), }); // 4. 调用工具 const result await useAgentOrchestrator().callTool(name, validatedArgs); // 5. 更新 store 中的 tool call 状态 if (result.success) { useConversationStore.getState().updateToolCall(toolCallId, { status: success, result: result.result, finishedAt: new Date(), }); } else { useConversationStore.getState().updateToolCall(toolCallId, { status: error, error: result.error, finishedAt: new Date(), }); } } catch (e) { // 校验失败或调用失败记录错误Agent 进入 error 状态 useConversationStore.getState().setError(e instanceof Error ? e.message : Tool call failed); } }Step 2Tool 结果如何喂回 LLMLLM 需要看到工具返回的结构化数据才能生成最终回复。所以必须把 tool call 的结果作为新的tool角色消息追加到 messages 数组末尾再发一次请求// 构造 tool 消息 const toolMessage { role: tool, content: JSON.stringify(toolResult), // 必须是字符串 tool_call_id: toolCallId, // 关联标识 }; // 新的 messages 数组 原 messages toolMessage const newMessages [...originalMessages, toolMessage]; // 再次调用 LLM这次它会看到工具返回的数据 const finalResponse await callLLM(newMessages);这个“LLM → Tool → LLM”的循环就是 Agent 的核心心跳。前端的工作就是确保每一次心跳都精准、可追踪、可中断。实操心得tool_call_id是关键纽带。它让 UI 能把“查天气”这个动作、它的参数、它的结果、它在最终回复中的引用全部串联起来。用户看到的不是冷冰冰的 JSON而是“根据天气 API 返回北京今日晴18°C建议穿衬衫——所以推荐您穿浅色衬衫搭配牛仔裤”。4.3 最终呈现如何让 UI 成为 Agent 的“情绪显示器”UI 是用户感知 Agent 智能的唯一窗口。一个优秀的 Agent UI必须传递三种情绪确定性我知道你在做什么、掌控感我能干预、信任感结果可靠。我用一个表格展示关键状态与 UI 响应Agent 当前状态UI 应呈现什么技术实现要点用户心理status: thinking输入框禁用显示“正在思考…” 微妙的呼吸动画用useTransition包裹状态更新避免 UI 卡顿动画用 CSSkeyframes不依赖 JS消除“没反应”的焦虑建立初步信任currentStep.type: tool-call在消息流底部显示CallingToolStep toolsearch_weather city北京 /带城市图标和旋转加载器Step 组件接收 props用useQuery的isPending控制加载状态图标用 SVG 内联避免网络请求让用户知道“它在查天气”而非“它在瞎想”toolCalls[n].status: success在对应 CallingToolStep 下方无缝插入ToolResultCard result{...} /展示结构化数据温度、湿度、建议Zustand store 更新后React 自动 re-renderCard 组件用shouldComponentUpdate优化性能提供透明度证明“它真的查到了”status: generatingAssistant 消息区域显示流式文本每个delta.content实时追加光标闪烁useEffect监听 store 中的 streaming 字段用requestIdleCallback批量更新 DOM创造“正在书写”的临场感比一次性弹出更自然status: error在消息流顶部显示醒目 banner“抱歉查天气时遇到问题。已为您重试或点击重试按钮。” 重试按钮Banner 用position: sticky确保滚动时始终可见重试按钮触发useAgentOrchestrator().retryLastTool()主动担责提供明确出路避免用户无助最关键的细节是状态过渡的平滑性。比如从 “thinking” 切换到 “calling-tool”不能突兀出现新组件而是用AnimatePresenceFramer Motion做淡入从 “calling-tool” 到 “tool-result”用layout属性让卡片高度自然展开。这些微交互是区分“能用”和“好用”的分水岭。注意事项所有 loading 状态必须有超时机制。我设定了全局 10 秒超时超时后自动显示“服务暂时繁忙”并提供“稍后重试”按钮。永远不要让用户盯着无限旋转的圈。5. 常见问题与排查技巧实录那些只有亲手撸过才懂的坑5.1 问题速查表高频故障现象与根因定位现象可能根因排查命令/技巧解决方案LLM 总是忽略 tool call 指令直接生成文本回复1. System Prompt 未正确包含 tool description2. LLM 模型不支持 function_call 模式3. messages 数组中缺少必要的 system message1.console.log(systemPrompt)检查是否包含 tool 描述2. 查阅模型文档确认是否支持functions参数3.console.log(messagesToSend)检查数组结构1. 严格按模型文档格式编写 description2. 切换支持 function_call 的模型如 gpt-3.5-turbo-11063. 确保 system message 是第一条Tool 调用后UI 不更新或更新延迟严重1. Zustand store 更新未触发 re-render常见于直接修改对象属性2.useQuery的staleTime设置过长缓存未及时失效3. React 18 的useTransition未正确包裹状态更新1.useConversationStore.getState().addToolCall({...})必须用 store 提供的方法2.queryClient.invalidateQueries({ queryKey: [weather] })强制刷新3.const [isPending, startTransition] useTransition(); startTransition(() {...})1. 永远通过 store 方法更新状态2. 在 tool call 成功后立即invalidateQueries3. 所有影响 UI 的状态更新必须包裹startTransition流式响应卡在某处后续 delta 不再到达1. 后端未正确发送data:前缀和换行符2. 前端ReadableStream未正确处理 chunk 边界3. 网络中间件如 Nginx缓冲了响应1. 用curl -N直接调用后端 API观察原始响应流2.console.log(chunk)查看原始二进制数据3. 检查 Nginx 配置添加proxy_buffering off;1. 后端确保每行以data:开头以\n\n结束2. 前端用TextDecodersplit(\n)安全解析3. 服务器配置关闭缓冲