上一篇我们搭好了一个「铁矿石价格预测专家」智能体,但它只能在命令行里跑。这一篇把它搬到网页上 :做一个聊天页面,后端提供流式(打字机效果)和非流式 两种接口,回答还能渲染成漂亮的 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
关键点:
ainvoke是 async 版的一次性调用,等模型全部生成完才返回。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. 小结
做一个「会打字」的智能问答页面,核心就三步:
- 服务层 :用
ainvoke(非流式)和astream(流式)把智能体包成两个函数; - 接口层 :流式用
StreamingResponse + SSE持续推送,非流式正常返回 JSON; - 前端 :
fetch读流、按 SSE 协议解析、用marked实时渲染 Markdown。
至此,智能体从「命令行玩具」变成了「能放给别人用的网页应用」。
本文基于 mystu 项目真实代码整理。下一步计划:加入多轮对话记忆(checkpointer + thread_id),以及把回答里的「思考过程」单独流式展示。