告别命令行,给你的 AI Agent 穿上"Web 外衣"。
在第二篇结束时,我们已经拥有了一个功能完备的 LangGraph 应用,它能在终端里进行多轮对话、调用工具、检索档案并保持记忆。但对于普通用户甚至前端开发人员来说,命令行交互并不友好。
今天我们重点攻克两大任务:
-
基于 FastAPI 封装异步 API 接口,支持流式输出(SSE)。
-
基于 Gradio 快速搭建带用户认证、会话管理和历史记录加载的 Web 界面。
一、FastAPI 接口开发:让后端服务化
将 Agent 封装为 API 服务,可以让前端(Web、App)方便地调用,也便于后续微服务架构的扩展。
1.1 项目启动入口(main.py)
我们需要一个统一的启动入口,负责初始化全局依赖(数据库连接池、LLM 实例、工具配置和状态图),并挂载 FastAPI 路由。
python
# main.py
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from api.routes import router as api_router
from config import Config
from llms import get_llm
from tools.tool_config import get_tools, ToolConfig
from graph.workflow import create_graph
from db.postgres_store import get_connection_pool
# 全局变量(懒加载)
graph_app = None
llm_chat = None
llm_embedding = None
@asynccontextmanager
async def lifespan(app: FastAPI):
"""应用生命周期管理:启动时初始化全局资源,关闭时清理连接池"""
global graph_app, llm_chat, llm_embedding
logging.info("🚀 正在初始化智能分诊系统...")
# 1. 初始化 LLM
llm_chat, llm_embedding = get_llm() # 默认使用 qwen
# 2. 初始化工具
tools = get_tools(llm_embedding) # 包含检索工具和 multiply
tool_config = ToolConfig(tools)
# 3. 创建数据库连接池
pool = get_connection_pool(Config.DB_URI)
# 4. 编译 LangGraph 应用
graph_app = create_graph(pool, llm_chat, llm_embedding, tool_config)
# 5. 保存可视化图片(便于调试)
from graph.workflow import save_graph_visualization
save_graph_visualization(graph_app, "graph.png")
logging.info("✅ 系统初始化完成")
yield # 应用运行中...
# 关闭时清理连接池
pool.close_all()
logging.info("🛑 系统已关闭")
# 创建 FastAPI 应用
app = FastAPI(
title="智能分诊系统 API",
description="基于 LangGraph 的医疗分诊 Agent 接口",
version="1.0.0",
lifespan=lifespan
)
# 配置 CORS(允许前端跨域访问)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # 生产环境请替换为具体域名
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 注册路由
app.include_router(api_router, prefix="/api")
1.2 核心 API 路由实现(chat 接口)
我们要实现一个支持**流式输出(Server-Sent Events)**的对话接口,让前端能像 ChatGPT 一样逐字接收回复。
python
# api/routes.py
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from typing import Optional, AsyncGenerator
import json
import asyncio
import logging
router = APIRouter()
class ChatRequest(BaseModel):
message: str
thread_id: str # 对应 LangGraph 的会话 ID
user_id: str = "default_user"
@router.post("/chat/stream")
async def chat_stream(request: ChatRequest):
"""流式对话接口,返回 text/event-stream"""
try:
# 从全局获取编译好的 graph_app
from main import graph_app
# 输入配置
config = {
"configurable": {
"thread_id": request.thread_id,
"user_id": request.user_id
}
}
# 构建输入消息
input_data = {
"messages": [{"role": "user", "content": request.message}]
}
async def event_generator() -> AsyncGenerator[str, None]:
"""异步生成 SSE 事件流"""
try:
# 使用 astream_events 获取细粒度的事件流
async for event in graph_app.astream_events(input_data, config, version="v2"):
kind = event["event"]
# 当 LLM 开始生成新 token 时
if kind == "on_chat_model_stream":
chunk = event["data"]["chunk"]
if chunk.content:
yield f"data: {json.dumps({'type': 'content', 'content': chunk.content})}\n\n"
# 当工具调用完成时
elif kind == "on_tool_end":
tool_output = event["data"]["output"]
yield f"data: {json.dumps({'type': 'tool_result', 'content': str(tool_output)})}\n\n"
# 当整个流程结束时
elif kind == "on_chain_end" and event["name"] == "LangGraph":
yield f"data: {json.dumps({'type': 'done'})}\n\n"
except Exception as e:
logging.error(f"流式生成错误: {e}")
yield f"data: {json.dumps({'type': 'error', 'content': str(e)})}\n\n"
finally:
# 结束标记
yield "data: [DONE]\n\n"
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no", # 避免 Nginx 缓冲
}
)
except Exception as e:
logging.error(f"请求处理失败: {e}")
raise HTTPException(status_code=500, detail=str(e))
1.3 历史会话管理接口
为了方便 WebUI 加载历史消息,我们还需要提供查询历史会话的接口。
python
@router.get("/history/{thread_id}")
async def get_history(thread_id: str, user_id: str = "default_user"):
"""获取指定会话的历史消息"""
from main import graph_app
config = {"configurable": {"thread_id": thread_id, "user_id": user_id}}
# 从检查点获取历史状态
state = graph_app.get_state(config)
if not state or not state.values:
return {"messages": []}
# 提取消息列表并格式化
messages = []
for msg in state.values.get("messages", []):
messages.append({
"role": msg.type, # human, ai, tool
"content": msg.content
})
return {"thread_id": thread_id, "messages": messages}
小贴士 :启动
main.py后,访问http://127.0.0.1:8000/docs即可看到自动生成的 Swagger API 文档,方便测试。
二、Gradio WebUI:给 AI 穿上一件漂亮的外衣
Gradio 是构建 AI 应用界面最快的 Python 库。我们不仅要做一个简单的聊天框,还要实现用户注册/登录 和历史会话加载,让体验更接近真实产品。
2.1 用户认证模块
为了区分不同用户的数据(记忆存储以 user_id 为命名空间),我们需要简单的注册/登录功能。这里使用内存字典模拟用户数据(生产环境应替换为数据库)。
python
# webui/auth.py
import hashlib
import json
import os
USER_DB_FILE = "users.json"
def hash_password(password: str) -> str:
return hashlib.sha256(password.encode()).hexdigest()
def load_users():
if os.path.exists(USER_DB_FILE):
with open(USER_DB_FILE, "r") as f:
return json.load(f)
return {}
def save_users(users):
with open(USER_DB_FILE, "w") as f:
json.dump(users, f)
def register(username: str, password: str) -> bool:
users = load_users()
if username in users:
return False
users[username] = hash_password(password)
save_users(users)
return True
def login(username: str, password: str) -> bool:
users = load_users()
if username not in users:
return False
return users[username] == hash_password(password)
2.2 主界面构建(webui.py)
我们需要构建一个包含"登录/注册"视图和"聊天"视图的界面。Gradio 的 Blocks 和 gr.State 非常适合做多页面状态管理。
python
# webui.py
import gradio as gr
import requests
import json
import uuid
from auth import login, register
BASE_URL = "http://127.0.0.1:8000/api"
# ---------- 全局状态 ----------
sessions = {} # {thread_id: [messages]}
# ---------- 核心交互函数 ----------
def chat_response(message, history, thread_id, user_id):
"""调用 FastAPI 流式接口,并逐字返回"""
if not thread_id:
thread_id = str(uuid.uuid4()) # 新会话自动生成 thread_id
# 构建请求
payload = {"message": message, "thread_id": thread_id, "user_id": user_id}
# 流式请求处理
try:
with requests.post(f"{BASE_URL}/chat/stream", json=payload, stream=True) as response:
if response.status_code != 200:
yield history + [(message, f"❌ 接口错误: {response.status_code}")]
return
# 解析 SSE 流
full_response = ""
for line in response.iter_lines():
if not line:
continue
line = line.decode('utf-8')
if line.startswith("data: "):
data = line[6:]
if data == "[DONE]":
break
try:
event = json.loads(data)
if event.get("type") == "content":
full_response += event.get("content", "")
# 实时更新界面
yield history + [(message, full_response)]
elif event.get("type") == "tool_result":
# 在界面上显示工具调用信息
tool_msg = f"🛠️ 工具调用: {event.get('content')}"
yield history + [(message, full_response + f"\n\n{tool_msg}")]
except json.JSONDecodeError:
continue
except Exception as e:
yield history + [(message, f"⚠️ 连接失败: {str(e)}")]
def load_history(thread_id, user_id):
"""加载指定会话的历史消息"""
try:
resp = requests.get(f"{BASE_URL}/history/{thread_id}?user_id={user_id}")
if resp.status_code == 200:
data = resp.json()
messages = data.get("messages", [])
# 转换为 Gradio 聊天格式 [(user, assistant), ...]
history = []
for i in range(0, len(messages)-1, 2):
if messages[i]["role"] == "human" and messages[i+1]["role"] == "ai":
history.append((messages[i]["content"], messages[i+1]["content"]))
return history, thread_id
except Exception as e:
print(f"加载历史失败: {e}")
return [], thread_id
# ---------- 界面布局 ----------
with gr.Blocks(theme=gr.themes.Soft(), title="智能分诊系统") as demo:
gr.Markdown("# 🏥 智能分诊系统")
gr.Markdown("基于 LangGraph 的 AI 医疗助手,支持健康档案检索、计算和记忆。")
# ---------- 登录/注册组件 ----------
with gr.Row():
with gr.Column(scale=1):
username_input = gr.Textbox(label="用户名", placeholder="请输入用户名")
password_input = gr.Textbox(label="密码", placeholder="请输入密码", type="password")
login_btn = gr.Button("登录", variant="primary")
register_btn = gr.Button("注册", variant="secondary")
status_msg = gr.Textbox(label="状态", interactive=False)
# ---------- 主聊天界面(初始隐藏) ----------
with gr.Row(visible=False) as chat_row:
with gr.Column(scale=4):
# 会话管理
with gr.Row():
new_session_btn = gr.Button("➕ 新建会话", variant="primary")
session_dropdown = gr.Dropdown(label="历史会话", choices=[], interactive=True)
refresh_btn = gr.Button("🔄 刷新会话列表", variant="secondary")
# 聊天组件
chatbot = gr.Chatbot(label="对话窗口", height=500)
msg = gr.Textbox(label="输入消息", placeholder="请输入您的健康问题...")
clear = gr.Button("🗑️ 清空当前会话")
# ---------- 状态存储 ----------
user_state = gr.State({"username": None, "thread_id": None})
# ---------- 事件绑定 ----------
# 登录逻辑
def do_login(username, password):
if login(username, password):
return (
gr.update(visible=True), # 显示聊天区
{"username": username, "thread_id": str(uuid.uuid4())},
f"✅ 登录成功!欢迎 {username}",
gr.update(choices=[]), # 清空下拉框(后续加载)
[]
)
else:
return gr.update(visible=False), {}, "❌ 用户名或密码错误", gr.update(choices=[]), []
login_btn.click(
do_login,
inputs=[username_input, password_input],
outputs=[chat_row, user_state, status_msg, session_dropdown, chatbot]
)
# 注册逻辑
def do_register(username, password):
if register(username, password):
return "✅ 注册成功,请登录!"
else:
return "❌ 用户名已存在,请更换。"
register_btn.click(do_register, inputs=[username_input, password_input], outputs=[status_msg])
# 发送消息
def send_message(message, history, user_state):
if not message.strip():
return history, ""
username = user_state.get("username")
thread_id = user_state.get("thread_id")
if not thread_id:
thread_id = str(uuid.uuid4())
user_state["thread_id"] = thread_id
# 调用流式接口
for updated_history in chat_response(message, history, thread_id, username):
yield updated_history, ""
msg.submit(send_message, inputs=[msg, chatbot, user_state], outputs=[chatbot, msg])
# 新建会话
def new_session():
new_id = str(uuid.uuid4())
return new_id, [], gr.update(value=None)
new_session_btn.click(new_session, outputs=[user_state, chatbot, session_dropdown])
# 加载历史会话
def load_selected_session(thread_id, user_state):
if thread_id:
user_state["thread_id"] = thread_id
history, _ = load_history(thread_id, user_state.get("username"))
return history, thread_id
return [], None
session_dropdown.change(
load_selected_session,
inputs=[session_dropdown, user_state],
outputs=[chatbot, user_state]
)
if __name__ == "__main__":
demo.launch(server_name="0.0.0.0", server_port=7860, share=False)
三、最终效果与测试
3.1 启动顺序
-
启动 PostgreSQL 容器(如果还没启动):
bash
docker-compose up -d -
启动 FastAPI 后端:
bash
python main.py -
启动 Gradio WebUI(新开一个终端):
bash
python webui.py -
浏览器访问
http://127.0.0.1:7860
3.2 功能验证
| 测试场景 | 操作 | 预期结果 |
|---|---|---|
| 用户注册 | 输入用户名/密码,点击注册 | 状态提示"注册成功" |
| 用户登录 | 输入已注册的账号密码 | 自动跳转到聊天界面,显示当前用户 |
| 基础对话 | 输入"你好" | 系统正常回复(无需工具) |
| 工具调用 | 输入"3乘以4等于多少" | 调用 multiply 工具,返回正确计算结果 |
| 知识检索 | 输入"张三九的健康档案" | 调用检索工具,返回档案信息 |
| 记忆功能 | 输入"记住我叫 Kevin" → 新建会话 → 问"我是谁" | 第二次对话能识别出"Kevin" |
| 历史会话加载 | 在"历史会话"下拉框中选中之前会话 | 聊天窗口自动加载该会话的历史消息 |
| 新建会话 | 点击"新建会话"按钮 | 清空当前聊天窗口,生成新的 thread_id |
四、部署与优化建议
4.1 生产环境注意事项
-
数据库连接池 :根据并发量调整
psycopg_pool的max_size和timeout参数。 -
敏感信息 :将数据库 URI、API Key 等放入
.env文件,使用python-dotenv加载。 -
用户认证:当前示例使用内存 + JSON 文件,生产环境应替换为 JWT + PostgreSQL 用户表。
-
日志归档 :项目已使用
ConcurrentRotatingFileHandler,建议配合定时任务(如 logrotate)进行日志归档清理。
4.2 性能调优
-
并行工具调用 :
ParallelToolNode的max_workers可根据服务器 CPU 核数调整(默认 5)。 -
检索重写次数 :在
route_after_grade中设置最大重写次数(项目为 3),避免死循环和 Token 浪费。 -
消息过滤 :
filter_messages限制保留最近 5 条消息,有效控制上下文长度。
五、系列总结与进阶方向
恭喜你!通过这三篇文章,我们完成了一个可投入实际使用的智能分诊系统。从项目的整体架构,到数据库与 LLM 的配置,再到 LangGraph 状态图的实现,最后封装为 API 并穿上 Web 界面------每一步都是 LangChain/LangGraph 生态中的典型实践。
回顾三篇的核心收获:
-
第一篇:项目规划、环境搭建、配置管理。
-
第二篇:工具定义、动态路由、状态图节点实现、记忆持久化。
-
第三篇:FastAPI 流式接口、Gradio 登录/聊天/历史会话 WebUI。
接下来你可以继续探索的方向:
-
多 Agent 协作 :使用 LangGraph 的
SubGraph实现医生、药师、导诊等多个 Agent 的协同。 -
更丰富的工具:接入病历生成、药品查询、预约挂号等真实 API。
-
评估与监控:集成 LangSmith 对 Agent 的每一步推理进行追踪和评分。
-
RAG 增强:使用更先进的混合检索(稠密+稀疏)和重排序(Reranker)提升检索精度。
AI 应用开发是一场马拉松,而 LangGraph 给了我们构建复杂 Agent 工作流的强大武器。希望这个系列能成为你实战路上的一个有力参考。如果在落地过程中遇到任何问题,欢迎随时交流探讨!
Happy Coding!🚀