1. 项目背景与核心挑战

去年在做一个智能客服系统时,我决定用FastAPI对接ollama的LLM大模型。本以为是个简单的接口对接,结果在实现流式响应(StreamingResponse)时,被Python的asyncio机制狠狠教育了一周。最崩溃的时刻是凌晨三点调试协程死锁,浏览器里堆积了47个未响应的请求。

这个项目本质上是要解决三个核心问题:

  1. 如何让FastAPI高效对接ollama的API(特别是流式输出)
  2. 如何正确处理asyncio的并发限制(避免事件循环阻塞)
  3. 如何实现带对话历史的Web交互界面

2. 技术栈选型解析

2.1 为什么选择FastAPI

FastAPI的异步特性天生适合LLM场景。实测对比Flask同步方案:

  • 并发能力提升3-8倍(取决于消息长度)
  • 延迟降低40%以上(特别是长文本生成)
  • 自带OpenAPI文档省去调试时间

但要注意:FastAPI默认使用starlette的Event Loop,而ollama的HTTPX客户端也需要事件循环,这就埋下了第一个坑。

2.2 ollama的特别之处

ollama的流式输出不同于普通API:

  • 响应头包含 content-type: text/event-stream
  • 数据格式是Server-Sent Events (SSE)
  • 每个chunk包含 data: 前缀和双换行符

典型响应示例:

data: {"model":"llama2","response":"Hello"}

data: {"model":"llama2","response":" World"}

[done]

2.3 asyncio的隐藏陷阱

测试时发现的现象:

  • 连续发送5个请求后服务卡死
  • 日志显示"Event loop is closed"
  • 有时浏览器收不到完整响应

根本原因是:

# 错误示范 - 会阻塞事件循环
async def generate():
    response = httpx.post(ollama_url)  # 同步风格的异步调用
    return response.text

3. 完整实现方案

3.1 正确的流式对接方案

核心代码结构:

from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
import httpx

app = FastAPI()

async def ollama_stream(prompt: str):
    async with httpx.AsyncClient(timeout=60.0) as client:
        async with client.stream(
            "POST",
            "http://localhost:11434/api/generate",
            json={"model": "llama2", "prompt": prompt},
            headers={"Accept": "text/event-stream"}
        ) as response:
            async for chunk in response.aiter_bytes():
                yield chunk

@app.post("/chat")
async def chat(request: Request):
    data = await request.json()
    return StreamingResponse(
        ollama_stream(data["prompt"]),
        media_type="text/event-stream"
    )

关键点:

  1. 必须使用 AsyncClient stream() 上下文
  2. aiter_bytes() aiter_text() 更可靠
  3. 超时设置至少60秒(大模型响应慢)

3.2 对话历史管理方案

前端常见错误是直接拼接对话历史,这会导致:

  • token数爆炸(超过模型限制)
  • 上下文混乱(角色错位)

推荐方案:

from collections import deque

class DialogueMemory:
    def __init__(self, max_turns=6):
        self.history = deque(maxlen=max_turns)
    
    def add(self, role: str, content: str):
        self.history.append({"role": role, "content": content})
    
    def format_prompt(self, new_query: str) -> str:
        context = "\n".join(
            f"{msg['role']}: {msg['content']}" 
            for msg in self.history
        )
        return f"{context}\nuser: {new_query}"

使用示例:

memory = DialogueMemory()
memory.add("user", "推荐上海的美食")
memory.add("assistant", "推荐小笼包和生煎")
prompt = memory.format_prompt("要排队吗?")

3.3 前端交互实现

基于Vue3的示例:

<template>
  <div class="chat-box">
    <div v-for="(msg, i) in messages" :key="i">
      <strong>{{ msg.role }}:</strong> {{ msg.content }}
    </div>
    <input v-model="inputMsg" @keyup.enter="send"/>
  </div>
</template>

<script setup>
import { ref } from 'vue'
const messages = ref([])
const inputMsg = ref('')

async function send() {
  const userMsg = inputMsg.value
  messages.value.push({role: 'user', content: userMsg})
  
  const response = await fetch('/chat', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({
      prompt: userMsg,
      history: messages.value
    })
  })
  
  const reader = response.body.getReader()
  let assistantMsg = ''
  while(true) {
    const {done, value} = await reader.read()
    if(done) break
    assistantMsg += new TextDecoder().decode(value)
    // 实时更新最后一条消息
    messages.value[messages.value.length-1].content = assistantMsg
  }
}
</script>

4. 性能优化实战

4.1 连接池配置

在main.py初始化时添加:

import httpx

@app.on_event("startup")
async def startup():
    app.state.httpx_client = httpx.AsyncClient(
        limits=httpx.Limits(
            max_connections=100,
            max_keepalive_connections=20
        ),
        timeout=60.0
    )

@app.on_event("shutdown")
async def shutdown():
    await app.state.httpx_client.aclose()

参数建议:

  • 每个worker保持10-20个长连接
  • 超时时间大于模型平均响应时间
  • 启用TCP Keepalive

4.2 流式响应压缩

修改StreamingResponse:

from fastapi.responses import StreamingResponse
import zlib

async def compressed_stream(generator):
    compressor = zlib.compressobj()
    async for chunk in generator:
        yield compressor.compress(chunk)
    yield compressor.flush()

@app.post("/chat")
async def chat(request: Request):
    stream = ollama_stream(await request.json())
    return StreamingResponse(
        compressed_stream(stream),
        media_type="text/event-stream",
        headers={"Content-Encoding": "deflate"}
    )

实测效果:

  • 带宽节省40-60%
  • CPU开销增加约15%
  • 建议在跨机房部署时启用

5. 避坑指南

5.1 asyncio常见死锁场景

  1. 混用同步/异步代码
# 错误!会阻塞事件循环
response = httpx.get(url)  # 同步调用
# 正确
response = await httpx.AsyncClient().get(url)
  1. 未正确关闭连接
# 错误!会导致连接泄漏
client = httpx.AsyncClient()
await client.get(url)
# 正确
async with httpx.AsyncClient() as client:
    await client.get(url)
  1. 任务未设置超时
# 危险!可能永久挂起
await asyncio.wait_for(ollama_stream(prompt), timeout=None)
# 建议
await asyncio.wait_for(ollama_stream(prompt), timeout=120)

5.2 ollama部署优化

国内下载慢的解决方案:

# 使用镜像源
OLLAMA_HOST=mirror.ollama.ai ollama pull llama2

# 或者手动下载后加载
ollama create llama2 -f Modelfile
ollama push llama2

模型目录迁移(解决默认盘空间不足):

# Linux/Mac
export OLLAMA_MODELS=/new/path
ollama serve

# Windows
set OLLAMA_MODELS=D:\ollama\models
ollama serve

5.3 生产环境建议

  1. 监控指标

    • 事件循环延迟( asyncio.all_tasks() 数量)
    • HTTPX连接池状态
    • 平均响应token/s
  2. 优雅降级方案

try:
    response = await ollama_stream(prompt)
except (httpx.ReadTimeout, asyncio.TimeoutError):
    return {"error": "模型响应超时"}
except httpx.ConnectError:
    return {"error": "模型服务不可用"}
  1. 负载测试建议
# 使用oha测试并发
oha -z 10s -c 50 --accept-json -m POST \
  -H "Content-Type: application/json" \
  -d '{"prompt":"test"}' \
  http://localhost:8000/chat

6. 扩展应用场景

6.1 多模型路由方案

from enum import Enum

class ModelType(str, Enum):
    LLAMA2 = "llama2"
    MISTRAL = "mistral"

@app.post("/chat/{model_type}")
async def chat(model_type: ModelType, request: Request):
    prompt = (await request.json())["prompt"]
    if model_type == ModelType.LLAMA2:
        stream = ollama_stream(prompt, model="llama2")
    else:
        stream = ollama_stream(prompt, model="mistral")
    return StreamingResponse(stream, media_type="text/event-stream")

6.2 带敏感词过滤的中间件

from fastapi import FastAPI, Request
from fastapi.middleware import Middleware

class ContentFilter:
    def __init__(self, app):
        self.app = app
        self.banned_words = ["暴力", "敏感词"]

    async def __call__(self, scope, receive, send):
        if scope["type"] == "http":
            request = Request(scope, receive)
            body = await request.body()
            if any(word in body.decode() for word in self.banned_words):
                raise HTTPException(status_code=400)
        await self.app(scope, receive, send)

app = FastAPI(middleware=[Middleware(ContentFilter)])

6.3 结合LangChain的进阶方案

from langchain_community.llms import Ollama
from langchain_core.prompts import ChatPromptTemplate

llm = Ollama(model="llama2")
prompt = ChatPromptTemplate.from_template(
    "你是一个专业厨师,用中文回答。问题:{query}"
)

chain = prompt | llm
result = await chain.ainvoke({"query": "如何做红烧肉?"})

实现这个项目后最大的体会是:异步编程就像做中餐,火候(事件循环)和食材(协程)的配合决定最终味道。最值得分享的经验是——永远要给ollama设置超时,我曾经因为一个复杂查询让服务器卡死了3小时。现在我的标准做法是在Nginx层再加一道60秒的超时:

location /chat {
    proxy_pass http://fastapi_backend;
    proxy_read_timeout 60s;
    proxy_send_timeout 60s;
}

更多推荐