把智能体做成服务

前面几章的智能体都在脚本里 run() 一下、打印结果。要接进网页/小程序/IM,得把"跑一次 Agent"变成一个 HTTP 接口:客户端 POST 问题、拿到回答。本章用 FastAPI 把第 18 章的 LangGraph 智能体封装成 /chat 服务——支持普通返回SSE 流式返回两种模式,含 pydantic 请求模型、CORS 与统一错误码,代码完整可跑。

安装依赖

pip install fastapi uvicorn langgraph langchain-openai pydantic

agent.py:第 18 章图的简写版

有第 18 章现成的图就直接 import;没有的话用下面这份等价的最小图(一个带 add 工具的 ReAct):

# agent.py —— 简写自第 18 章;结构相同,可整体替换为你的 graph
import os
from typing import Annotated, TypedDict
from langchain_core.messages import HumanMessage
from langchain_core.tools import tool
from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langgraph.prebuilt import ToolNode

@tool
def add(a: int, b: int) -> int:
    """计算两个整数的和"""
    return a + b

model = ChatOpenAI(model=os.getenv("LLM_MODEL", "deepseek-chat"),
                   api_key=os.getenv("LLM_API_KEY"),
                   base_url=os.getenv("LLM_BASE_URL"))
model_with_tools = model.bind_tools([add])

class State(TypedDict):
    messages: Annotated[list, add_messages]

def agent(state):
    return {"messages": [model_with_tools.invoke(state["messages"])]}

def should_continue(state):
    last = state["messages"][-1]
    return "tools" if getattr(last, "tool_calls", None) else END

builder = StateGraph(State)
builder.add_node("agent", agent)
builder.add_node("tools", ToolNode([add]))
builder.add_edge(START, "agent")
builder.add_conditional_edges("agent", should_continue, {"tools": "tools", END: END})
builder.add_edge("tools", "agent")
graph = builder.compile()

app.py:/chat 与 /chat/stream

# app.py
import json
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from langchain_core.messages import HumanMessage
from agent import graph

app = FastAPI(title="Agent 服务")
app.add_middleware(CORSMiddleware, allow_origins=["*"],       # 前端域名按需收紧
                   allow_methods=["*"], allow_headers=["*"])

class ChatRequest(BaseModel):
    message: str = Field(min_length=1, max_length=4000, description="用户输入")
    session_id: str | None = None       # 有则复用会话,无则新建
    stream: bool = False                # 简化版可忽略;下面用不同路由实现

class ChatResponse(BaseModel):
    reply: str
    session_id: str

def new_session_id() -> str:
    import uuid
    return str(uuid.uuid4())[:8]

def raise_api(status: int, code: str, message: str):
    # 统一错误体:{"error": {"code": ..., "message": ...}}
    raise HTTPException(status_code=status, detail={"code": code, "message": message})

@app.get("/healthz")
def healthz():
    return {"status": "ok"}

@app.post("/chat", response_model=ChatResponse)
def chat(req: ChatRequest):
    """同步接口:等 Agent 全部跑完再一次性返回。"""
    sid = req.session_id or new_session_id()
    try:
        out = graph.invoke({"messages": [HumanMessage(req.message)]})
        reply = next((m.content for m in reversed(out["messages"]) if m.content), "")
    except Exception as e:
        raise_api(500, "AGENT_ERROR", f"Agent 执行失败:{e}")
    return ChatResponse(reply=reply, session_id=sid)

def sse_events(message: str):
    """同步生成器:LangGraph 逐 token 出内容,包成 SSE 事件。"""
    for _chunk, meta in graph.stream(
        {"messages": [HumanMessage(message)]}, stream_mode="messages"):
        piece = _chunk.content
        if not piece:
            continue
        yield f"data: {json.dumps({'delta': piece}, ensure_ascii=False)}\n\n"
    yield "data: [DONE]\n\n"      # 前端收到即结束

@app.post("/chat/stream")
def chat_stream(req: ChatRequest):
    """SSE 流式接口:一边生成一边推给客户端。"""
    if req.message.startswith("禁止"):       # 演示错误分支;真实校验见第 19 章
        raise_api(400, "BAD_REQUEST", "请求被拒绝")
    return StreamingResponse(sse_events(req.message), media_type="text/event-stream")

说明两点:同步节点跑模型会阻塞事件循环,上面两个端点都写成 def(非 async),FastAPI 会自动丢线程池执行,互不卡死;SSE 用 data: ...\n\n 分隔事件,浏览器 EventSource / 前端 fetch 流都能解析。

启动与验证

uvicorn app:app --host 0.0.0.0 --port 8000 --reload

另开终端验证:

curl http://127.0.0.1:8000/healthz
# 输出:{"status":"ok"}

curl -X POST http://127.0.0.1:8000/chat \
  -H "Content-Type: application/json" \
  -d '{"message": "25 加 17 等于多少?"}'
# 输出:{"reply":"25 加 17 等于 42。","session_id":"3f9a2c1e"}

curl -N -X POST http://127.0.0.1:8000/chat/stream \
  -H "Content-Type: application/json" -d '{"message":"写一句自我介绍"}'
# 输出(逐条推送):data: {"delta": "你好"}  …  data: [DONE]

浏览器侧消费:new EventSource 不支持 POST,可用 fetch + ReadableStream 逐行读 data: 行,或换 GET + query 参数(把 message 放 query,EventSource 即可直接用,详见第 21 章末尾)。

错误码约定

HTTPcode含义处理建议
400BAD_REQUEST参数非法/被拦截客户端修正输入
404NOT_FOUND会话不存在等检查 session_id
429RATE_LIMITED触发了限流/配额退避重试
500AGENT_ERROR模型或工具异常看服务日志,可让客户端重试
所有错误统一为 {"error": {"code": ..., "message": ...}},前端按 code 分支展示,不猜 HTTP 状态之外的细节。

多轮会话怎么存

示例里没接会话历史,每请求都是新对话。要记住上下文,用第 20 章的做法:compile(checkpointer=MemorySaver()),把 session_id 映射成 thread_id 传进 config,同 session 的后续请求自动带上历史。生产环境把 MemorySaver 换成 Redis/数据库 checkpointer,再给 /chat 加鉴权与限流(可参考第 29 章容器化一起上线)。 小结:FastAPI 封装智能体 = 一个 graph.invoke 的同步端点 + 一个把 graph.stream(stream_mode="messages") 逐 token 包成 SSE 的流式端点,入参出参用 pydantic 建模、CORS 放行前端、错误统一成 code/message 结构;端点为普通 def 时 FastAPI 自动走线程池,不会阻塞事件循环。

笔记加载中…