当前位置: 首页 > news >正文

HTML+fastAPI+Dify|打通前后端至智能体的路

从一个真实demo项目出发,讲清楚 AI 聊天应用里最关键的流式通信模式。


前言

当你你在聊天页面输入【各科平均分是多少?请用柱状图展示】,点击发送。几秒后 AI 像打字机一样逐字吐出答案,最后还附上一张 ECharts 图表。

这个过程看似简单,背后却是一段精心设计的旅程:

浏览器 → FastAPI → Dify AI Agent → FastAPI → 浏览器

该篇将带你一步步拆解其中的关键节点,帮助你建立【流式通信】的完整流程。


一、三个角色

角色技术栈职责
前端HTML + JS + ECharts收集输入、打字机效果、渲染图表
后端Python FastAPI + httpx代理请求、安全转发流数据
DifyDify NLP2SQL Agent理解自然语言、查库、生成回答

为什么不直接从前端请求 Dify?API Key 写在前端 JS 里,任何人打开开发者工具都能看到。正确做法是前端 → 后端 → Dify。就像去餐厅点菜,你告诉服务员,服务员有钥匙进厨房。


二、一次请求的完整旅程

1. 前端发送请求

用户点击发送后,前端发起 POST 请求(因为是在【提交】问题,而非【获取】页面):

const response = await fetch("/api/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ question: "各科平均分是多少?", conversation_id: "" }), });

2. 后端接收并返回流式响应

FastAPI 用 Pydantic 定义请求结构,然后返回StreamingResponse:

class ChatRequest(BaseModel): question: str conversation_id: str = "" ​ @app.post("/api/chat") async def chat(req: ChatRequest): return StreamingResponse( ask_dify_stream(req.question, req.conversation_id), media_type="text/event-stream", )

核心决策:返回 StreamingResponse而非JSONResponse,media_type设为text/event-stream——告诉浏览器数据会一段一段来

3. 后端请求 Dify

后端从.env读取 Dify 配置,组装请求:

headers = {"Authorization": f"Bearer {DIFY_API_TOKEN}"} payload = { "inputs": {}, "query": question, "response_mode": "streaming", # 关键:要求 Dify 流式返回 "conversation_id": conversation_id, "user": "web-user", }

然后用httpx流式请求:

async with httpx.AsyncClient(timeout=None) as client: async with client.stream("POST", DIFY_CHAT_URL, headers=headers, json=payload) as resp: ...

timeout=None避免 AI 生成较慢时过早断连。

4. Dify 处理并流式返回

Dify 的 NLP2SQL Agent 收到问题后:NLP 理解意图,SQL 转化为数据库查询。然后边生成边以 SSE 格式返回:

data: {"event":"message","answer":"各科平均分如下:"} ​ data: {"event":"message","answer":"语文 86,数学 91,英语 88。"} ​ data: {"event":"message_end","conversation_id":"abc-123"}

5. 后端原样转发

后端逐行读取 Dify 返回,遇到data:开头就转发——不改内容,纯粹的传声筒:

async for line in resp.aiter_lines(): if line.startswith("data:"): yield line + "\n\n"

yield 而非return——一点一点「让渡」数据。

6. 前端流式读取 —— 核心差异

这是全文最重要的部分。前端拿到response后,没有这样做:

// 普通做法:等全部返回后一次性解析 const data = await response.json();

而是这样做了:

// 流式做法:自己动手,一块一块读 const reader = response.body.getReader(); const decoder = new TextDecoder("utf-8"); let buffer = ""; ​ while (true) { const { done, value } = await reader.read(); if (done) break; ​ buffer += decoder.decode(value, { stream: true }); const events = buffer.split("\n\n"); buffer = events.pop(); // 最后一个可能不完整,留到下次拼接 ​ for (const item of events) { const line = item.split("\n").find(r => r.startsWith("data:")); if (!line) continue; ​ const data = JSON.parse(line.slice(5)); // 去掉 "data:" 前缀 ​ if (data.event === "message" || data.event === "agent_message") { fullAnswer += data.answer || ""; aiMessageText.textContent = fullAnswer; // 实时更新页面! } } }

两种写法的本质区别:

response.json()response.body.getReader()
获取方式等服务器全部返回完返回一点读一点
体验等待后一次性出现打字机效果
内存全部加载流式处理
场景普通 API、配置AI 聊天、实时日志
底层内部 read→JSON.parse直接操作 ReadableStream

比喻:response.json()是厨师做完一整桌菜再端出来,getReader()是做好一道端一道。

7. 流结束后渲染 Markdown 与图表

这里没有导入其他库来做markdown的渲染,而是采用了替换的方式

流式接收期间,前端用textContent纯文本显示,避免 Markdown 不完整导致渲染错乱。结束后做三件事:

  1. 渲染 Markdown## 标题**加粗**等转为 HTML。

  2. 提取并渲染 ECharts

const regex = /```(?:json|echarts)?\s*([\s\S]*?)```/g; const json = JSON.parse(match[1]); if (json.xAxis || json.series) { // 用 ECharts 渲染 const chart = echarts.init(chartDiv); chart.setOption(json); }

三、为什么这样设计?

1. 安全性:API Key 只存于服务端.env,浏览器代码中找不到任何密钥。

2. 体验:非流式下用户要等十几秒才看到回复;流式模式下第一秒就有反馈。

3. 解耦:前端只知道/api/chat和 SSE 协议。后端换 AI 服务商,前端无需改动。


四、动手实践:最小示例

后端(FastAPI):

import asyncio, json, uvicorn from fastapi import FastAPI from fastapi.responses import StreamingResponse, FileResponse from starlette.staticfiles import StaticFiles app = FastAPI( title="流式输出测试", version="1.0.0" ) async def generate(): for word in ["你好", ",", "这是", "流式", "响应", "。"]: yield f"data: {json.dumps({'event': 'message', 'answer': word})}\n\n" await asyncio.sleep(0.3) @app.get("/") async def root(): return FileResponse("index.html") @app.post("/api/chat") async def chat(): return StreamingResponse(generate(), media_type="text/event-stream")

前端(流式读取):

(async () => { const response = await fetch("/api/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ question: "你好" }), }); const reader = response.body.getReader(); const decoder = new TextDecoder("utf-8"); let buffer = ""; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const events = buffer.split("\n\n"); buffer = events.pop(); for (const item of events) { const line = item.split("\n").find(r => r.startsWith("data:")); if (line) console.log(JSON.parse(line.slice(5)).answer); } } })();

五、常见问题速查

现象原因解决
发送失败后端未启动访问/api/health
has_token: false.env未配置检查根目录.env
Dify 401Key 错误重新复制密钥
有文字无图表JSON 缺少xAxis/series检查代码块结构
显示了图表代码JSON 格式非法确保纯 JSON

六、总结

这个项目的核心架构可以用四句话概括:

前端负责交互 —— 收集输入、打字机效果、渲染图表 后端负责安全转发 —— 持有密钥、代理请求、原样转发 SSE Dify 负责 AI —— NLP2SQL 理解意图、生成回答 前端负责最终展示 —— Markdown 转换、JSON 提取、ECharts 渲染

而贯穿全链路的那条金线,就是流式通信——从 Dify 的response_mode: "streaming",到后端的httpx.stream()+yield,再到前端的response.body.getReader(),三个环节环环相扣,共同构成了打字机般的流畅体验。

当从const data = await response.json()走向const reader = response.body.getReader()的那一刻,完成的不仅是一次 API 调用的升级,更是从「请求-响应」思维到「流式通信」思维的跃迁。理解了这一点,你就掌握了现代 AI 聊天应用最核心的通信模式。

http://www.jsqmd.com/news/934058/

相关文章:

  • 别再只跑Demo了!Grounding DINO实战:用你自己的数据集做Fine-tuning(附完整代码)
  • 索尼发布带 ‘True RGB‘ 背光的 Bravia 9 II 和 Bravia 7 II,色彩表现更出色!
  • 别再只用plt.plot了!Matplotlib面向对象接口实战:从脚本到Notebook的完整配置指南
  • 在Visual Studio中集成Python、Jupyter与.NET,打造高效研究工作站
  • 如何打造高效AI研究周报:从信息筛选到团队洞察的完整指南
  • 我为什么要使用Ollama配置通义千问大模型
  • 红相EDMI电表通信调试助手:报文拆解、CRC校验、地址与序列号互转
  • 【Sora 2教育视频制作黄金法则】:20年AI教育专家亲授5大不可绕过的生成逻辑与避坑指南
  • 避坑指南:在RK3588/树莓派等ARM开发板上调试Linux休眠唤醒,你得先搞懂PSCI与cpu_ops
  • 别再混淆了!一文讲透STM32的UART、TTL、RS232、RS485和MODBUS协议关系
  • QKeyMapper终极指南:5分钟掌握Windows最强输入映射工具,告别操作烦恼!
  • C++类和对象(上):一文搞懂基础定义与核心规则
  • Debugger Canvas:可视化调试如何革新代码调试的认知模式
  • 前期安装虽需功夫,但后续操作简单,还支持多实用功能!
  • 36小时打造AR内容推荐引擎:从PWA到向量检索的实战解析
  • 聚力绿色包装创新,interpack China×WPO 上海盛会 11 月启幕
  • 从系统脆弱性到韧性架构:如何防范分布式系统中的“缺口末日”
  • UE5新手避坑指南:手把手教你开启Lumen全局光照,告别漫长的光照烘焙
  • 5分钟快速上手Blue Topaz:打造你的专属Obsidian蓝色主题
  • Win10开机报No Bootable Device别慌!从拍打到重装,我试了这5种方法(附详细命令)
  • 电网设备拓扑图一键自动排布工具(基于FR力导向算法)
  • 职场人必备!高颜值电脑音乐播放器YesPlayMusicV0.4.10
  • LangChain4j AiServices 机制详解:快速构建智能体应用
  • 从Grudin定律到协同设计:人机交互与CSCW的核心思想与实践
  • WSL2下Docker容器GPU挂载报错?手把手教你修复‘libnvidia-ml.so.1: file exists’问题
  • HoloLens 2学术研究指南:混合现实技术原理、开发流程与创新应用
  • 用STM32F103C8T6和AD9850自制高精度信号发生器,从电路焊接、代码编写到波形测试全流程避坑
  • 从Haskell到工程实践:函数式编程思想如何提升代码质量
  • 从Imagine Cup 2011冠军项目看传感器与机器学习的工程实践
  • 第130期《Installer》推荐:多款新品、屏幕分享、读者好物及Spotify实用功能!