这是我在学习企业级 AI Agent + RAG 时做的一个实践 Demo:上传文档构建个人/企业知识库,再用自然语言提问,Agent 会先判断是否需要检索知识库,最终以「流式打字机」的方式给出带完整推理过程的回答。本文主要记录它的技术栈、实现难点与心得。
🧱 项目概览
Demo 采用前后端分离的 Monorepo 结构,两个应用独立运行:
Enterprise-server/— Python 3.12 + FastAPI 后端:LangChain/LangGraph Agent 负责对话编排,RAG 检索落到 Milvus,Embedding 由本地 Ollama 提供Enterprise-web/— Vue 3 + Vite + TypeScript 前端:聊天页 + 知识库上传页,通过 SSE 实时渲染流式回答
核心数据流如下:
用户提问 │ fetch POST /chat/stream(SSE) ▼FastAPI 路由 ──► LangGraph Agent(DeepSeek) │ │ 判断是否调用工具 │ ▼ │ RagQuery 工具 ──► 问题向量化(Ollama nomic-embed-text) │ ▼ │ Milvus 相似度检索(top_k 片段) │ │ ◄── SSE 事件流 start / reasoning / tool_call / text / complete / done ▼Vue3 前端逐段渲染(markstream)+ 思考过程面板 + 工具调用标签🛠️ 技术栈
后端(Enterprise-server)
| 分类 | 选型 | 说明 |
|---|---|---|
| 语言 / Web 框架 | Python 3.12 + FastAPI + uvicorn | 异步路由,StreamingResponse 输出 text/event-stream |
| Agent 编排 | LangChain 1.2 + LangGraph 1.1 | create_agent 构建带工具 + 中文系统提示词的 Agent;stream_events(..., version="v3") 拿流式事件 |
| 大模型 | DeepSeek(deepseek-v4-flash) | 通过 init_chat_model(model_provider="deepseek") 接入 |
| Embedding | Ollama 本地服务 nomic-embed-text | 768 维向量,本地跑、免费、不依赖外网 |
| 向量数据库 | Milvus(pymilvus + langchain-milvus) | 集合 KnowledgeBase,COSINE 相似度、string 主键、dimension=768 |
| 文档处理 | LangChain Loaders(Text / PyPDF / Docx2txt / Markdown)+ RecursiveCharacterTextSplitter | 按扩展名自动选 loader,中文标点感知的分块 |
前端(Enterprise-web)
| 分类 | 选型 | 说明 |
|---|---|---|
| 框架 | Vue 3.5 + Vite + TypeScript | <script setup lang="ts">,vue-tsc 类型检查 |
| 状态管理 | Pinia | chatstore(对话流)/ filestore(文件生命周期) |
| 流式渲染 | markstream-vue | Markdown「打字机」式逐段输出 |
| SSE 解析 | fetch + ReadableStream + TextDecoder | 手动按 \n\n 切帧解析 data: JSON |
| 代理 | Vite dev-proxy | /chat、/file 代理到 :8000 后端 |
文档摄入管线
前端拖拽上传(支持 .pdf / .docx / .md)→ 后端落盘 → 按扩展名选择 Loader 加载 → 中文分块 → 向量化 → 逐 chunk upsert 到 Milvus(每行携带 content 文本与 source_file / chunk_index 元数据)。前端为每个文件维护 uploading → uploaded → vectorizing → vectorized 的完整生命周期状态。
🚧 遇到的困难
1. 把 Agent 的「内部过程」翻译成前端可渲染的事件协议
LangGraph stream_events 输出的是增量式的多类型事件:思考内容(reasoning)、工具调用(tool_call)、最终回复(text)交织出现,且同一个 tool_call 可能跨多条增量消息重复发送。
解法:后端自定义一套 SSE 事件协议
start / reasoning / tool_call / text / complete / done,在生成器里统一归类转发;前端applyEvent按type分支处理,并对 tool_call 按工具名去重,避免重复渲染。难点在于这套协议由两端共同维护——后端 generator 和前端 store 任何一边改动,另一边都要同步。
2. Vue 流式渲染的「响应式陷阱」
把「正在流式生成」的 assistant 消息 push 进 store 时,如果只把它当成 ref 里的普通对象,闭包内逐字符地 content += delta 是不会触发视图更新的,markstream 也就不会动。
解法:必须用
reactive()包裹该消息对象后再 push,让每次增量写入都经过代理,即时触发模板重渲染。这个细节代码注释里专门标了「关键」二字,属于踩过才懂的坑。
3. 中文文档切分不是「默认就能用」
RecursiveCharacterTextSplitter 的默认分隔符是面向英文的,直接切中文容易在句子中间下刀,把语义切断,直接影响检索召回质量。
解法:自定义分隔符优先级列表,把中文标点放进去:
\n\n → \n → 。→ !→ ?→ ;→ ,→ 空格,再配合chunk_size=500、chunk_overlap=75反复调参。RAG 效果的天花板往往由切分质量决定,而不是模型。
4. 向量库维度与配置的一致性
Milvus 集合创建时的 dimension 必须和 Embedding 模型输出维度严格一致(这里都是 768),metric_type=COSINE、id_type=string。维度对不上时写入或检索会直接失败。
解法:把
MILVUS_DIMENSION、CHUNK_SIZE、CHUNK_OVERLAP、TOP_K=6等全部收敛到config.py单一配置源,换 Embedding 模型时必须同步核对维度。
5. 摄入链路长、失败点分散
上传 → 校验扩展名 → 落盘 → 选 Loader → 切分 → Embedding → 逐块 upsert,是一条很长的链路,还涉及路径遍历防护、文件名冲突处理、文件不存在等边界情况。
解法:后端统一返回
{ code, message, data }信封,前端以code === 200判定成功,据此维护每个文件的生命周期与错误提示,把「哪一步挂了」清晰暴露给用户。
6. 返回结果不正确/找不到
如果用户在系统输入的内容过于模糊,过少等情感,检索返回的答案会出现不匹配、找不到的情况,这时就需要通过增加tok_k参数以以提高命中率。后续考虑使用更加细致的langgraph节点编排提高系统的可用性
💡 心得
-
Agent 的关键不是「调 API」,而是编排与工具设计。 一个
@tool(RagQuery)+create_agent+ 一句系统提示词就能成型;真正有门槛的是把 Agent 的推理链路设计得清晰、可解释。 -
流式的本质是「事件协议」而非 HTTP 传输。 只要把事件
type定义清楚、前端增量拼接足够,就能把后端生成器与前端 UI 彻底解耦。但这要求协议双端维护、最好有注释或文档作为。 -
让思考过程可见是很好的 Agent 产品体验。 可折叠的「推理过程」面板 + 工具调用标签,让用户能看懂模型「为什么这么答」,既是调试手段,也让 Demo 更有说服力、更适合现场演示。
-
原型阶段优先用「本地 + 低成本」组件。 Ollama 本地免费做 Embedding、DeepSeek API 成本低、Milvus 起在 localhost 就能把链路跑通;等链路验证完,再把向量库 / Embedding 按需迁到云端或集群。
-
Monorepo 里后端生成器与前端 store 是天然的一对。 聊到 SSE,后端是「生成方」、前端是「消费方」,两边的状态机(streaming / complete / error)最好保持一致语义,否则会出现「后端已 done、前端还卡在 buffering」之类的错位。
有问题? 欢迎在评论区交流讨论!
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时




