从零手搓一个 AI 助手:我的 Search-First 聊天机器人实战复盘
一、为什么非要自己做?
二、技术选型:不搞花里胡哨,只选实用高效的方案
|
技术层面 |
技术方案 |
选型理由(核心亮点) |
|---|---|---|
|
前端框架 |
Next.js 16 (App Router) |
全栈一体,API Route 可直接写后端逻辑,无需额外搭建服务器,前端开发者零门槛上手 |
|
UI 层 |
React 19 + Tailwind CSS 4 |
快速出活,样式无需反复调试,Tailwind 原子化样式适配聊天界面,响应式友好 |
|
LLM(大模型) |
DeepSeek (deepseek-chat) |
性价比拉满,兼容 OpenAI 接口,调用成本低,响应速度快,适合个人开发者 |
|
Agent 框架 |
LangChain |
AI 工具调用编排的行业标准,封装完善,可快速扩展多工具,降低开发成本 |
|
搜索引擎 |
Tavily |
专为 AI 设计的搜索 API,返回结构化结果(摘要+来源),无需手动解析网页,省时间 |
|
开发语言 |
TypeScript (strict) |
类型安全,减少低级错误,尤其适合前后端协同开发,代码可维护性更高 |
三、最关键的架构决策:Search-First(搜索优先)
先说说常见的坑(大多数 Agent 新手会踩)
// 做法一的思路:把搜索作为 Tool 交给 Agentconst searchTool = new DynamicTool({name: "web_search",description: "搜索互联网获取实时信息",func: async (query) => { /* 调用搜索API */ }});const agent = createReactAgent({ llm, tools: [searchTool, timeTool] });
我的解决方案:Search-First 架构(服务端主导搜索)
// api/agent/route.ts — 核心路由(后端入口)export async function POST(req: Request) {const { message, history } = await req.json();// 第一步:服务端先用关键词规则判断需不需要搜索if (needsRealtimeSearch(message)) {// Path A: 搜索优先模式(有实时需求,先搜再生成)return await handleSearchFirst(message, history);} else {// Path B: 普通 Agent 模式(无实时需求,直接对话)return await handleAgentMode(message, history);}}
Search-First 完整流程(一目了然)
关键细节 1:关键词分类器(零成本、零延迟)
// api/agent/route.ts — 核心路由(后端入口)export async function POST(req: Request) {const { message, history } = await req.json();// 第一步:服务端先用关键词规则判断需不需要搜索if (needsRealtimeSearch(message)) {// Path A: 搜索优先模式(有实时需求,先搜再生成)return await handleSearchFirst(message, history);} else {// Path B: 普通 Agent 模式(无实时需求,直接对话)return await handleAgentMode(message, history);}}function needsRealtimeSearch(message: string): boolean {const keywords = [// 金融类(实时性强)"股票", "基金", "汇率", "A股", "美股", "比特币",// 时间类(需最新信息)"今天", "昨天", "最新", "近期", "现在",// 新闻类(时效性强)"新闻", "热点", "突发", "大事", "发生了什么",// 天气/体育(实时数据)"天气", "气温", "比赛", "比分", "NBA", "世界杯",// 显式搜索意图"搜索", "查一下", "帮我查"];return keywords.some(kw => message.includes(kw));}
关键细节 2:搜索结果注入方式(让 LLM 自动带来源)
async function handleSearchFirst(message: string, history: Message[]) {// 1. 先调用 Tavily 搜索,获取结构化结果const searchResult = await performSearch(message);// 2. 构建增强版 System Prompt,强制要求标注来源const systemPrompt = `你是一个智能助手,严格基于以下最新搜索结果回答用户问题,必须标注信息来源(URL),语言简洁专业:${searchResult}注意:仅用搜索结果中的信息回答,不添加无关内容,来源标注在回答末尾,格式为【来源:XXX】。`;// 3. 组装消息(历史+当前提问+增强 Prompt),发起流式调用const messages = [{ role: "system", content: systemPrompt },...history.slice(-20), // 只取最近20条历史,避免token超标{ role: "user", content: message }];return llm.stream(messages); // SSE 流式输出,前端实时渲染}
四、Agent 模式:什么时候不走搜索?
async function handleAgentMode(message: string, history: Message[]) {const tools = [new DynamicTool({name: "get_current_time",description: "获取当前日期和时间(中国上海时区)",func: () => {const now = new Date();return `当前时间:${now.toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" })}`;}})];const agent = createAgent({ llm, tools });return agent.stream({ messages: [...history, userMessage] });}
五、前端实现要点(770 行代码,新手可直接复用)
1. 核心状态管理(localStorage 持久化)
// 核心状态(React Hooks)const [conversations, setConversations] = useState<Conversation[]>([]); // 所有会话const [activeId, setActiveId] = useState<string | null>(null); // 当前激活的会话const [input, setInput] = useState(""); // 输入框内容const [loading, setLoading] = useState(false); // 加载状态const [streamingContent, setStreamingContent] = useState(""); // 流式输出内容
2. SSE 流式处理(核心难点,附完整代码)
async function sendToAPI(message: string) {// 先获取当前会话的历史消息(避免闭包陷阱,后文会说)const currentMsgs = conversations.find(c => c.id === activeId)?.messages ?? [];const history = currentMsgs.map(({ role, content }) => ({ role, content }));setLoading(true);setStreamingContent("");try {const response = await fetch("/api/agent", {method: "POST",headers: { "Content-Type": "application/json" },body: JSON.stringify({ message, history })});if (!response.ok) throw new Error("请求失败");const reader = response.body!.getReader();const decoder = new TextDecoder();let fullContent = "";// 逐块读取 SSE 数据while (true) {const { done, value } = await reader.read();if (done) break;const chunk = decoder.decode(value, { stream: true });// 解析 SSE 格式:data: {...}\n\n(按换行符分割,避免数据边界问题)for (const line of chunk.split("\n")) {if (line.startsWith("data: ")) {const data = JSON.parse(line.slice(6));fullContent += data.content;setStreamingContent(fullContent); // 实时更新 UI,实现打字机效果}}}// 流结束,保存完整消息到当前会话addAIMessage(fullContent);} catch (err) {console.error("请求错误:", err);setStreamingContent("请求失败,请重试~");} finally {setLoading(false);}}
3. UX 细节优化(提升用户体验,加分项)
4. 运行结果

六、整体架构图(一目了然,新手可对照搭建)
┌─────────────────────────────────────────────┐│ 浏览器 (Next.js Frontend) ││ ┌──────────┐ ┌──────────────────────────┐ ││ │ 侧边栏 │ │ 聊天主区域 │ ││ │ · 新建对话 │ │ · 消息列表 (Markdown) │ ││ │ · 会话列表 │ │ · 输入框 (自适应高度) │ ││ │ · 删除/切换 │ │ · 流式输出 (光标动画) │ ││ └──────────┘ └──────────┬───────────────┘ │└───────────────────────────┼───────────────────┘│ POST /api/agent (SSE)▼┌───────────────────────────────────────────────┐│ Next.js API Route (后端) ││ ││ 用户消息 ──→ 关键词匹配? ││ │ ││ ┌─────────┴─────────┐ ││ ▼ ▼ ││ [含实时关键词] [普通问题] ││ │ │ ││ ▼ ▼ ││ Tavily 搜索 LangChain Agent ││ │ (timeTool) ││ ▼ │ ││ 注入 System Prompt │ ││ │ │ ││ └───────┬───────────┘ ││ ▼ ││ DeepSeek LLM (流式) ││ │ ││ ▼ ││ SSE Stream → 前端逐字显示 │└───────────────────────────────────────────────┘
七、踩过的坑 & 经验总结(避坑指南,新手必看)
1. React 闭包陷阱(最容易踩的坑)
// ✅ 正确:发送前捕获快照(关键一步)const currentMsgs = conversations.find(c => c.id === activeId)?.messages ?? [];const history = currentMsgs.map(({ role, content }) => ({ role, content }));// ❌ 错误:异步回调中直接读 state,可能拿到旧值const resp = await fetch(...);// 这里的 conversations 可能还是请求前的旧值!
2. SSE 数据边界问题
3. localStorage 大小限制
夜雨聆风