乐于分享
好东西不私藏

从零手搓一个 AI 助手:我的 Search-First 聊天机器人实战复盘

从零手搓一个 AI 助手:我的 Search-First 聊天机器人实战复盘

作为前端开发者,你是否也厌倦了“只调 API 做 Demo”的浅层实践?市面上 ChatGPT、Claude、Kimi 等 AI 聊天产品虽好用,但亲手造轮子,才是吃透 AI Agent 技术的最佳路径。
今天就带大家从零搭建一个「具备联网搜索能力」的 AI 聊天应用——用 Next.js + DeepSeek + LangChain + Tavily,不仅实现基础对话,更能自动联网获取实时信息,还能做到 SSE 流式输出、Markdown 渲染,全程实战可复现,文末附完整开源地址。
先放最终效果:ChatGPT 风格界面,AI 会根据你的问题自动判断是否需要联网,回答时自带搜索来源标注,体验拉满👇(可联想 ChatGPT Browsing 模式,但全程自研可控)

一、为什么非要自己做?

市面上的 AI 聊天工具再多,也满足不了开发者的“定制化需求”——我要做的不是一个简单的对话 Demo,而是一个真正具备「工具调用能力」的 AI Agent,具体要实现这 4 点:
✅ 能联网搜索实时信息(新闻、股价、天气、热点,告别 AI 信息滞后问题)
✅ 支持多轮对话+会话管理,刷新页面不丢失聊天记录
✅ SSE 流式输出,打字机效果,体验和 ChatGPT 几乎一致
✅ 美观实用的 UI(侧边栏会话切换、Markdown 渲染、代码高亮+复制功能)
对开发者而言,这不仅是一个练手项目,更能吃透 LangChain 工具编排、SSE 流式通信、前后端协同等核心能力,这些都是 AI 应用开发的必备技能。

二、技术选型:不搞花里胡哨,只选实用高效的方案

全程围绕「前端友好」和「高效落地」选型,核心技术栈如下,每一步都有明确的选型理由,新手可直接参考:

技术层面

技术方案

选型理由(核心亮点)

前端框架

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)

类型安全,减少低级错误,尤其适合前后端协同开发,代码可维护性更高

核心依赖就这些,没有多余冗余的包。package.json 核心依赖清单(新手可直接复制安装):next、react、@langchain/openai、langchain、zod、tailwindcss。

三、最关键的架构决策:Search-First(搜索优先)

这是整个项目的灵魂,也是我踩了很多坑后,最值得分享的优化点——放弃“让 LLM 自己决定是否搜索”,改用“服务端先搜,再喂给 LLM”,这也是 ChatGPT Browsing 模式的核心逻辑。

先说说常见的坑(大多数 Agent 新手会踩)

做法一:让 LLM 自己判断是否调用搜索工具(LangChain createReactAgent 默认做法)
// 做法一的思路:把搜索作为 Tool 交给 Agentconst searchTool new DynamicTool({  name"web_search",  description"搜索互联网获取实时信息",  funcasync (query) => { /* 调用搜索API */ }});const agent createReactAgent({ llm, tools: [searchTool, timeTool] });
看似合理,但实际落地有 3 个致命问题:
多一次 LLM 调用:Agent 要先“思考”要不要搜索,额外消耗 token 和时间,延迟翻倍
判断不准:LLM 可能对“是否需要搜索”产生误判(比如不知道自己信息滞后)
体验差:用户问“今天 A 股怎么样”,要先等 LLM 决定“要搜索”,再等搜索返回,再等 LLM 生成回答,等待时间太长

我的解决方案:Search-First 架构(服务端主导搜索)

核心思想:不让 LLM 做“决策”,只让它做“生成”;服务端先通过规则判断是否需要搜索,完成搜索后,再把结果注入 LLM 上下文,让 LLM 直接生成回答。
核心代码(API 路由,最关键的逻辑):
// 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 完整流程(一目了然)

用户提问 → 服务端关键词匹配 → 调用 Tavily 搜索→ 将搜索结果注入 System Prompt → LLM 流式生成 → SSE 返回前端
整个过程中,LLM 只做一件事:基于已有的搜索结果+对话历史,生成连贯、带来源的回答,不用再“纠结”要不要搜索,效率和体验都大幅提升。

关键细节 1:关键词分类器(零成本、零延迟)

没有用额外的 LLM 做分类(避免增加延迟和成本),而是用「关键词匹配」,简单粗暴但高效,覆盖 6 大类高频场景,约 60 个关键词:
// 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 自动带来源)

Tavily 搜索 API 会返回两个核心内容:「answer」(AI 总结的摘要)和「results」(带标题、URL、内容的搜索结果列表),我将它们格式化后,直接拼到 System Prompt 中,让 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 模式:什么时候不走搜索?

不是所有问题都需要联网——比如“帮我写个快排算法”“解释一下闭包是什么”“获取当前时间”这类知识性、非实时问题,直接走普通 Agent 模式,效率更高。
目前我只给 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] });}
重点提醒:搜索工具没有放在 Agent 的工具列表里!搜索是由服务端在 Agent 之前统一处理的——这是 Search-First 架构的核心,也是和普通 Agent 最大的区别。

五、前端实现要点(770 行代码,新手可直接复用)

前端是一个单页组件(page.tsx),核心是「状态管理」和「SSE 流式处理」,再加上一些 UX 细节优化,让体验更流畅。

1. 核心状态管理(localStorage 持久化)

所有会话数据存在 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 流式处理(核心难点,附完整代码)

这是前端实现“打字机效果”的关键,用 ReadableStream 配合 TextDecoder 逐块解析 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" },      bodyJSON.stringify({ message, history })    });    if (!response.okthrow 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, { streamtrue });      // 解析 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 细节优化(提升用户体验,加分项)

停止生成:用 AbortController.abort() 中断流式请求,已生成内容保留,标记“(已停止生成)”
重新生成:一键删除最后一条 AI 回复,自动重新发送当前用户消息,无需重新输入
自动标题:取首条用户消息前 20 个字符作为会话标题,无需手动命名
Markdown 渲染:支持代码块(带复制按钮)、行内代码、加粗、斜体,适配技术类对话
快捷键:Enter 发送消息,Shift+Enter 换行,符合日常使用习惯
欢迎页:4 张建议卡片(如“查今天的 A 股行情”“解释闭包”),引导用户快速上手

4. 运行结果 

六、整体架构图(一目了然,新手可对照搭建)

┌─────────────────────────────────────────────┐│ 浏览器 (Next.js Frontend) ││ ┌──────────┐ ┌──────────────────────────┐ ││ │ 侧边栏 │ │ 聊天主区域 │ ││ │ · 新建对话 │ │ · 消息列表 (Markdown) │ ││ │ · 会话列表 │ │ · 输入框 (自适应高度) │ ││ │ · 删除/切换 │ │ · 流式输出 (光标动画) │ ││ └──────────┘ └──────────┬───────────────┘ │└───────────────────────────┼───────────────────┘ │ POST /api/agent (SSE) ▼┌───────────────────────────────────────────────┐│ Next.js API Route (后端) ││ ││ 用户消息 ──→ 关键词匹配? ││ │ ││ ┌─────────┴─────────┐ ││ ▼ ▼ ││ [含实时关键词] [普通问题] ││ │ │ ││ ▼ ▼ ││ Tavily 搜索 LangChain Agent ││ │ (timeTool) ││ ▼ │ ││ 注入 System Prompt │ ││ │ │ ││ └───────┬───────────┘ ││ ▼ ││ DeepSeek LLM (流式) ││ │ ││ ▼ ││ SSE Stream → 前端逐字显示 │└───────────────────────────────────────────────┘

七、踩过的坑 & 经验总结(避坑指南,新手必看)

这个项目看似简单,但落地时踩了 3 个关键的坑,分享出来帮大家少走弯路:

1. React 闭包陷阱(最容易踩的坑)

问题:流式更新时,直接读取 state 里的 conversations 构建历史消息,拿到的可能是过期数据(React state 是异步更新的)。
解决方案:在发送请求前,用变量捕获当前会话的消息快照,后续异步操作都用这个快照,避免读取旧 state:
// ✅ 正确:发送前捕获快照(关键一步)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 数据边界问题

问题:TCP 包不会按 SSE 的「data: …\n\n」格式对齐,一个 chunk 可能包含多条不完整的 SSE 消息,直接解析会报错。
解决方案:按换行符分割 chunk,只处理以「data: 」开头的完整行,忽略不完整的片段(代码里已处理,可直接复用)。

3. localStorage 大小限制

问题:localStorage 一般只有 5MB 空间,若对话很长(尤其是包含大量代码、搜索结果),容易爆仓。
临时解决方案:API 端只发送最近 20 条历史消息,减少 token 消耗;后续优化方向:添加本地存储清理机制(比如删除超过 30 天的会话)。

八、下一步优化方向(可落地的迭代计划)

目前的实现已经是一个可用的 MVP(最小可行产品),但还有很多可扩展的方向,适合继续深耕:
🔧 扩展更多工具:计算器、代码执行(sandbox)、数据库查询、本地文件读写
📚 接入 RAG:添加私有知识库(比如自己的笔记、公司文档),让 AI 能回答专属问题
🎯 多模态支持:图片识别、语音输入/输出,提升交互体验
🔄 用户系统:登录注册、云端同步会话(替代 localStorage),多设备互通
📝 Prompt 优化:精细化系统提示词,提升回答质量和来源标注规范性
🤖 搜索分类器升级:从关键词匹配,升级为轻量级分类模型(比如 DeepSeek 小模型),提升判断准确率

九、写在最后

这个项目从零到能用,核心代码量其实不多——后端 API 路由 330 行,前端组件 770 行,加起来也就 1100 行左右,但“麻雀虽小,五脏俱全”:流式输出、工具调用、联网搜索、会话管理、持久化存储,该有的核心功能一个不少。
对我而言,最大的收获不是完成了一个 Demo,而是理解了「Search-First」的设计哲学:有时候最好的技术方案,不是让 AI 更“聪明”地做决策,而是在 AI 做决策之前,帮它把准备工作做好——在正确的环节做正确的事,才能兼顾效率和体验。
如果你也在折腾 AI Agent 开发,希望这篇复盘能给你一点启发。目前项目已开源到 Gitee(地址:https://gitee.com/wanghuanlmz/my-agent-project),欢迎 star、fork,也可以在评论区交流你的架构思路和踩坑经历,一起进步~
最后,觉得文章有用的话,麻烦点赞、在看、转发三连,你的支持是我继续分享的动力!🙏