我的第一个智能问答:给智能体接上网页 + 流式输出

上一篇我们搭好了一个「铁矿石价格预测专家」智能体,但它只能在命令行里跑。这一篇把它搬到网页上 :做一个聊天页面,后端提供流式(打字机效果)非流式 两种接口,回答还能渲染成漂亮的 Markdown


1. 目标:从命令行到网页对话

之前是这样用的:

bash 复制代码
uv run python -m mystu.buildagent.agent.deepagent   # 命令行里 print 一段回答

这一篇要实现的效果:

  • 打开浏览器就能聊天,像 ChatGPT 那样;
  • 回答一个字一个字蹦出来(流式),而不是干等十几秒再整段出现;
  • 回答里的标题、列表、表格、加粗能正确排版(Markdown 渲染);
  • 同时保留一个非流式接口(一次性返回完整结果),方便程序对接。

最终启动方式:

bash 复制代码
uv run python main.py     # 然后打开 http://127.0.0.1:5000/

2. 整体架构

复制代码
浏览器页面 (static/index.html)
   │  fetch POST
   ├── /agent/api/chat          → 非流式:等待完整回答,一次性返回 JSON
   └── /agent/api/chat/stream   → 流式:SSE 逐片段推送
        │
        ▼
FastAPI 接口层 (controller/api.py)
        │  调用
        ▼
服务层 (deepagent.py)
   ├── achat(message)        → await agent.ainvoke(...)
   └── astream_chat(message) → agent.astream(..., stream_mode="messages")
        │
        ▼
   铁矿石专家智能体 (deepagents + DeepSeek)

一句话:页面 → 接口 → 服务函数 → 智能体,流式与非流式只是「服务函数」和「响应方式」不同。


3. 后端:两个接口

3.1 服务层:把智能体包装成两个函数

智能体(LangGraph 编译出的图)本身既支持「一次拿全部结果」,也支持「边生成边产出」。我们各包一个函数:

python 复制代码
async def achat(message: str) -> str:
    """非流式问答:返回完整回答文本。"""
    result = await agent.ainvoke(
        {"messages": [{"role": "user", "content": message}]}
    )
    return _extract_text(result["messages"][-1].content)


async def astream_chat(message: str):
    """流式问答:逐 token 产出回答文本片段。"""
    async for chunk, _metadata in agent.astream(
        {"messages": [{"role": "user", "content": message}]},
        stream_mode="messages",
    ):
        if isinstance(chunk, AIMessageChunk):
            text = _extract_text(chunk.content)
            if text:
                yield text

关键点:

  • ainvokeasync 版的一次性调用,等模型全部生成完才返回。
  • astream(stream_mode="messages") 会把模型正在生成的每个 token 作为 AIMessageChunk 吐出来------这就是「打字机效果」的来源。
  • async def + yield 组成异步生成器,是 Python 里「流」的标准写法。

给 Java 同学:async def ≈ 返回 CompletableFuture 的方法;yield 的异步生成器 ≈ 响应式里的 Flux<String>

3.2 接口层:非流式很简单

python 复制代码
@router.post("/chat")
async def chat(request: apireq.ChatRequest) -> apirsp.Response:
    answer = await achat(request.message)
    return apirsp.Response(data=answer)

请求 {"message": "..."},返回 {"data": "完整回答"}。和普通 REST 接口没区别。

3.3 接口层:流式用 SSE

流式响应用的是 SSE(Server-Sent Events) ------一种「服务器持续往浏览器推数据」的标准协议,本质是一个不关闭的 HTTP 响应 ,按 data: xxx\n\n 的格式一段段发。

python 复制代码
@router.post("/chat/stream")
async def chat_stream(request: apireq.ChatRequest) -> StreamingResponse:
    async def event_generator():
        try:
            async for token in astream_chat(request.message):
                yield f"data: {json.dumps({'token': token}, ensure_ascii=False)}\n\n"
        except Exception as exc:
            yield f"data: {json.dumps({'error': str(exc)}, ensure_ascii=False)}\n\n"
        finally:
            yield "data: [DONE]\n\n"

    return StreamingResponse(event_generator(), media_type="text/event-stream")

约定的「协议」很简单:

事件 含义
data: {"token": "片段"} 一段新生成的文本
data: {"error": "..."} 出错信息
data: [DONE] 生成结束
  • media_type="text/event-stream" 告诉浏览器「这是 SSE,别等它结束」。
  • 每段 token 都 JSON 编码,避免文本里的换行/特殊字符破坏 SSE 格式。

4. 前端:解析流 + 渲染 Markdown

4.1 为什么不用 EventSource

浏览器原生的 EventSource 只支持 GET ,而我们要 POST (请求体里带 message)。所以用 fetch + ReadableStream 手动读流:

js 复制代码
const resp = await fetch('/agent/api/chat/stream', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ message }),
});

const reader = resp.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '', answer = '';

while (true) {
  const { value, done } = await reader.read();
  if (done) break;
  buffer += decoder.decode(value, { stream: true });

  const events = buffer.split('\n\n');
  buffer = events.pop();               // 末尾可能是半个事件,留到下次

  for (const evt of events) {
    if (!evt.trim().startsWith('data:')) continue;
    const payload = evt.trim().slice(5).trim();
    if (payload === '[DONE]') return;
    const obj = JSON.parse(payload);
    if (obj.token) {
      answer += obj.token;
      renderMarkdown(bubble, answer);  // 累计后整体渲染
    }
  }
}

要点:网络包的边界不等于 事件边界,所以用 buffer 累积,按 \n\n 切分,不完整的尾巴留到下一次再拼。

4.2 Markdown 渲染

模型回答是 Markdown 原文(#**|表格|)。直接显示会很丑,所以用 marked 解析成 HTML,再用 DOMPurify 消毒防 XSS:

js 复制代码
function renderMarkdown(bubble, text) {
  if (window.marked) {
    const html = marked.parse(text, { gfm: true, breaks: true });
    bubble.innerHTML = DOMPurify.sanitize(html);
    bubble.classList.add('md');
  } else {
    bubble.textContent = text;   // 库没加载时退化为纯文本
  }
}

流式时每来一段就用「累计文本」整体重渲染一次,于是标题、列表、表格都会随着生成实时成形


5. 用到了哪些组件

组件 位置 职责
FastAPI 后端 提供 /chat/chat/stream 两个接口
StreamingResponse 后端 把异步生成器包装成持续输出的 HTTP 响应(SSE)
uvicorn 后端 ASGI 服务器,跑起整个应用
LangGraph astream 服务层 让智能体「边想边吐字」,stream_mode="messages" 拿 token 级片段
fetch + ReadableStream 前端 POST 请求并手动读取流式响应
marked 前端 把 Markdown 文本解析成 HTML
DOMPurify 前端 过滤 HTML,防止 XSS

6. 小结

做一个「会打字」的智能问答页面,核心就三步:

  1. 服务层 :用 ainvoke(非流式)和 astream(流式)把智能体包成两个函数;
  2. 接口层 :流式用 StreamingResponse + SSE 持续推送,非流式正常返回 JSON;
  3. 前端fetch 读流、按 SSE 协议解析、用 marked 实时渲染 Markdown。

至此,智能体从「命令行玩具」变成了「能放给别人用的网页应用」。


本文基于 mystu 项目真实代码整理。下一步计划:加入多轮对话记忆(checkpointer + thread_id),以及把回答里的「思考过程」单独流式展示。

相关推荐
专注VB编程开发20年1 小时前
Python爬虫、提取网页内容,免费调用谷歌翻译接口
爬虫·python·信息可视化
gCode Teacher 格码致知1 小时前
Python教学:十六进制编码的显示方法-由Deepseek产生
开发语言·python·算法
biter down1 小时前
8:YAML 语法
运维·python
知识分享小能手1 小时前
数据预处理入门学习教程,从入门到精通,数据获取 — 知识点详解与案例代码(4)
python·学习·pandas
weixin_468466851 小时前
深度学习损失函数新手实战指南
人工智能·python·深度学习·算法·机器学习·ai
biter down1 小时前
3.Python 接口自动化之 Pytest 测试框架
开发语言·python
天天爱吃肉82181 小时前
【汽车研发测试工程师|Python自动化实测全套脚本(CAN解析+数据处理+自动出报告)】
大数据·python·功能测试·嵌入式硬件·汽车
梅羽落1 小时前
WIFI破解
开发语言·python
xxxxxue1 小时前
Windows 通过 右键菜单 调用 Python 脚本
开发语言·windows·python·右键菜单