下面按照你阶段一的七个模块,逐一给出可直接落地的知识点与代码实例。所有代码以 Python 3.11+ / FastAPI / Pydantic v2 / OpenAI 兼容 API 为基准,并尽量保持"最小可运行"形态,你可以直接复制到项目中逐步验证。
模块 1:Python 异步------I/O 与 CPU 的边界,以及 FastAPI 事件循环
核心认知
asyncio 的价值只出现在 I/O 等待 场景:一个单线程事件循环在 await 点切换协程,从而在等待网络/数据库时去处理其他任务。一旦在事件循环中执行 time.sleep()、同步 requests.get() 或 CPU 密集计算,整个进程的所有协程都会被冻结------这是 FastAPI 接口"莫名其妙变慢"的头号原因。
选型规则可以压缩成一张表:
| 任务类型 | 正确工具 | 原因 |
|---|---|---|
| 网络 I/O(LLM API、HTTP) | asyncio + httpx.AsyncClient |
等待期间事件循环可调度其他协程 |
同步阻塞库(旧版 requests、同步 ORM) |
asyncio.to_thread / ThreadPoolExecutor |
把阻塞调用桥接到线程,不冻结事件循环 |
| CPU 密集(大量解析、数值计算) | ProcessPoolExecutor + loop.run_in_executor |
绕开 GIL,真正并行 |
代码实例:并发调用 3 个 LLM API(含超时与部分失败容忍)
python
import asyncio
import httpx
from typing import Any
LLM_ENDPOINTS = [
{"url": "https://api.example.com/v1/chat/completions", "payload": {...}},
{"url": "https://api.example.com/v1/chat/completions", "payload": {...}},
{"url": "https://api.example.com/v1/chat/completions", "payload": {...}},
]
async def call_one(client: httpx.AsyncClient, endpoint: dict) -> dict[str, Any]:
resp = await client.post(
endpoint["url"],
json=endpoint["payload"],
timeout=httpx.Timeout(connect=3.0, read=30.0),
)
resp.raise_for_status()
return resp.json()
async def call_all_llms():
async with httpx.AsyncClient() as client:
results = await asyncio.gather(
*[call_one(client, ep) for ep in LLM_ENDPOINTS],
return_exceptions=True, # 关键:单个失败不取消其他
)
for i, r in enumerate(results):
if isinstance(r, Exception):
print(f"call {i} failed: {type(r).__name__}: {r}")
else:
print(f"call {i} ok, keys={list(r.keys())}")
return results
# asyncio.run(call_all_llms())
return_exceptions=True 是生产环境的基本要求:没有它,任何一个 LLM 调用超时都会让 gather 整体抛异常,其余请求的结果也拿不到。如果希望"兄弟任务失败即取消全部",Python 3.11+ 应优先使用 asyncio.TaskGroup。
代码实例:FastAPI 中正确桥接同步阻塞与 CPU 密集
python
import asyncio
from concurrent.futures import ProcessPoolExecutor
from fastapi import FastAPI
app = FastAPI()
cpu_pool = ProcessPoolExecutor()
def blocking_db_query(user_id: int) -> dict:
import time; time.sleep(0.5) # 模拟同步数据库驱动
return {"user_id": user_id, "name": "Alice"}
def heavy_parse(raw: str) -> dict:
# 模拟 CPU 密集:大量字符串/数值处理
for _ in range(10_000_000):
pass
return {"len": len(raw)}
@app.get("/user/{user_id}")
async def get_user(user_id: int):
# 同步阻塞 → 丢进线程池,不冻结事件循环
data = await asyncio.to_thread(blocking_db_query, user_id)
return data
@app.post("/parse")
async def parse_text(raw: str):
loop = asyncio.get_running_loop()
# CPU 密集 → 进程池,绕开 GIL
result = await loop.run_in_executor(cpu_pool, heavy_parse, raw)
return result
能解释 FastAPI 事件循环并发原理(面试口径)
FastAPI 的 async def 路由运行在 uvicorn 的单线程事件循环 上。请求到来时,协程开始执行;遇到 await(例如 await client.post(...)),控制权交还事件循环,事件循环去处理下一个已就绪的请求或回调。因此,并发能力来自"等待期间切换" ,而不是多线程并行。一旦路由内部出现未包裹的同步阻塞调用,该请求会独占事件循环,所有其他请求(包括健康检查)都会被卡住------这就是为什么生产代码中必须对同步调用做 to_thread / 进程池隔离。
模块 2:FastAPI 工程------依赖注入、生命周期、结构化日志、tenacity、全局异常
依赖注入与生命周期
FastAPI 的 Depends 构成依赖树,同一个请求内默认缓存共享实例,适合把数据库会话、服务对象、当前用户逐层注入。lifespan 用异步上下文管理器管理"应用启动到关闭"的资源(连接池、HTTP 客户端、进程池),避免全局变量在导入时被初始化。
python
from contextlib import asynccontextmanager
from fastapi import FastAPI, Depends
import httpx
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.http = httpx.AsyncClient(timeout=10.0)
yield
await app.state.http.aclose()
app = FastAPI(lifespan=lifespan)
async def get_http() -> httpx.AsyncClient:
return app.state.http
async def get_llm_service(http: httpx.AsyncClient = Depends(get_http)):
return LLMService(http)
@app.post("/chat")
async def chat(svc: LLMService = Depends(get_llm_service)):
return await svc.run(...)
tenacity 重试:只重试"值得重试"的异常
生产环境不要对所有异常无差别重试。超时、429、5xx 适合指数退避;400/401/422 是客户端错误,重试没有意义。tenacity 的 retry_if_exception_type + wait_exponential + stop_after_attempt 是标准组合。
python
import httpx
from tenacity import (
retry, stop_after_attempt, wait_exponential,
retry_if_exception_type, before_sleep_log,
)
import logging
logger = logging.getLogger(__name__)
class RetryableLLMError(Exception): ...
@retry(
retry=retry_if_exception_type((httpx.TimeoutException, httpx.HTTPStatusError, RetryableLLMError)),
wait=wait_exponential(multiplier=1, min=1, max=10),
stop=stop_after_attempt(3),
before_sleep=before_sleep_log(logger, logging.WARNING),
reraise=True,
)
async def call_llm_with_retry(http: httpx.AsyncClient, payload: dict):
resp = await http.post("https://api.example.com/v1/chat/completions", json=payload)
if resp.status_code == 429 or resp.status_code >= 500:
raise RetryableLLMError(f"status={resp.status_code}")
resp.raise_for_status()
return resp.json()
结构化日志 + 全局异常处理器
structlog 把每条日志输出为 JSON 行,便于 ELK/Loki 检索。配合全局异常处理器,保证任何未捕获异常都返回可追溯的 request_id,而不是裸 500。
python
import uuid, structlog
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
structlog.configure(
processors=[structlog.processors.TimeStamper(fmt="iso"), structlog.processors.JSONRenderer()],
)
log = structlog.get_logger()
app = FastAPI()
@app.middleware("http")
async def request_id_middleware(request: Request, call_next):
request_id = request.headers.get("X-Request-ID") or str(uuid.uuid4())
request.state.request_id = request_id
with structlog.contextvars.bound_contextvars(request_id=request_id):
response = await call_next(request)
response.headers["X-Request-ID"] = request_id
return response
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
log.error("unhandled_exception", exc_info=exc, path=request.url.path)
return JSONResponse(
status_code=500,
content={"detail": "internal_error", "request_id": request.state.request_id},
)
@app.get("/health/live")
async def liveness():
return {"status": "alive"}
@app.get("/health/ready")
async def readiness():
# 这里检查 DB / Redis / 下游 LLM 可达性
return {"status": "ready"}
模块 3:Pydantic------从工具参数模型到标准 OpenAI Tool Schema
用 Pydantic 定义工具参数,自动生成 OpenAI Tool Schema
手写 JSON Schema 极易与执行代码漂移。正确做法是:Pydantic 模型是唯一真相源 ,既用于运行时参数校验,也通过 model_json_schema() 生成发给模型的 tool schema。
python
from enum import Enum
from pydantic import BaseModel, Field, field_validator
from typing import Any
class Unit(str, Enum):
CELSIUS = "celsius"
FAHRENHEIT = "fahrenheit"
class GetWeatherArgs(BaseModel):
location: str = Field(..., min_length=1, description="城市与国家,例如 'Lisbon, PT'")
unit: Unit = Field(default=Unit.CELSIUS, description="温度单位")
@field_validator("location")
@classmethod
def strip_location(cls, v: str) -> str:
return v.strip()
def to_openai_tool(
model: type[BaseModel],
name: str,
description: str,
) -> dict[str, Any]:
schema = model.model_json_schema()
if "$defs" in schema: # Pydantic v2 嵌套模型用 $defs
schema["definitions"] = schema.pop("$defs")
schema.pop("title", None) # 减小 payload
return {
"type": "function",
"function": {
"name": name,
"description": description,
"parameters": schema,
},
}
tool_schema = to_openai_tool(
GetWeatherArgs,
"get_weather",
"查询指定城市的当前天气。仅当用户询问天气时使用。",
)
生成的 tool_schema["function"]["parameters"] 就是标准的 OpenAI Function Calling 参数结构,包含 properties、required、additionalProperties: false 以及字段描述。field_validator 会在模型实例化时自动执行清洗/拒绝,例如把 skills=["Python", " ", "Flink"] 自动处理为 ["Python", "Flink"]。
模块 4:LLM 认知------Prompt、温度、幻觉成因与缓解
Temperature 的真实行为
温度控制 token 采样的随机度:0.0 接近贪婪解码,0.1--0.3 接近确定,0.7--1.0 明显发散、更容易编造。但一个被广泛误解的事实是:temperature=0 不能消除幻觉,只会让幻觉变成"稳定复现的同一个错误" 。因此,低温度必须配合其他手段。
三个真实幻觉案例与分层缓解
案例 A:RAG 有正确上下文,模型仍"补充"了上下文没有的预测。
- 缓解:
temperature=0;System Prompt 硬约束------"仅根据提供的上下文回答,如果信息不在上下文中,说'我没有这个信息',禁止使用通用知识";生成后用 NLI 模型逐条声明验证是否被上下文支持。公开案例中该组合可将幻觉率从 18% 降到 3%。
案例 B:两个药物的剂量在不同 chunk 中被混淆。
- 缓解:减少 chunk 数量(只保留最相关的 3--5 个);给 chunk 打结构化标签(
MOST RELEVANT:/SUPPORTING:);重排确保被查询实体的信息排在前面。
案例 C:长输出越往后越偏离事实。
- 缓解:收紧
max_tokens。事实检索类任务给 256--512 tokens 通常足够;输出越长,模型"游移"的空间越大。同时把top_p降到 0.1--0.5,top_k=5左右,形成保守的采样组合。
工程结论:幻觉不是单点问题。推理参数只是第一层;RAG 是第二层(把生成锚定在检索事实上);生成后验证(NLI、引用标注、自检)是第三层。稳健系统需要多层叠加。
模块 5:Token 管理------精确计数、预算控制、截断与成本统计
关键认知:tiktoken.encode(content) 不等于"一条 message 的 token 数"
OpenAI 的 chat 消息有格式开销:每条消息基础开销、role、name、content 分别计数,整个请求还有固定尾部开销。直接用 len(encoding.encode(content)) 会系统性低估,导致预算判断失准。下面是一个保守但接近真实的计数器。
python
import tiktoken
from typing import Iterable
class TokenCounter:
def __init__(self, model: str = "gpt-4o-mini"):
try:
self.enc = tiktoken.encoding_for_model(model)
except KeyError:
self.enc = tiktoken.get_encoding("o200k_base")
def text(self, s: str) -> int:
return len(self.enc.encode(s))
def message(self, m: dict) -> int:
overhead = 5
if m.get("name"):
overhead += self.text(m["name"])
overhead += self.text(m["role"])
overhead += self.text(m["content"] or "")
return overhead
def messages(self, msgs: Iterable[dict]) -> int:
return sum(self.message(m) for m in msgs) + 3 # 整体尾部开销
保留最近 N Token 的对话压缩 + 成本统计
python
class TokenBudgetMemory:
def __init__(self, max_tokens: int, model: str = "gpt-4o-mini"):
self.max_tokens = max_tokens
self.counter = TokenCounter(model)
self.messages: list[dict] = []
self.total_prompt_tokens = 0
self.total_completion_tokens = 0
def add(self, role: str, content: str):
self.messages.append({"role": role, "content": content})
self._trim()
def _trim(self):
"""保留 system,然后从旧到新移除非 system 消息,直到预算内。"""
while self.counter.messages(self.messages) > self.max_tokens:
idx = next((i for i, m in enumerate(self.messages) if m["role"] != "system"), None)
if idx is None:
raise RuntimeError("system message alone exceeds budget")
self.messages.pop(idx)
def record_usage(self, usage):
self.total_prompt_tokens += usage.prompt_tokens
self.total_completion_tokens += usage.completion_tokens
def estimated_cost(self, input_price_per_1m: float, output_price_per_1m: float) -> float:
return (
self.total_prompt_tokens / 1_000_000 * input_price_per_1m
+ self.total_completion_tokens / 1_000_000 * output_price_per_1m
)
TokenBudgetMemory 把度量从"消息条数"改为"Token 数",因为不同消息的 token 量差异巨大,按 token 才能精确控制成本和上下文使用率。record_usage 接收 API 返回的 usage 对象,累计后即可按你所用模型的单价估算成本。
模块 6:流式输出------SSE 协议与 FastAPI StreamingResponse
SSE 的最小格式
SSE 要求每条事件以 data: 开头、以两个换行 \n\n 结尾。前端 EventSource 或 fetch 流式读取时,依据这两个换行切分事件。media_type 必须是 text/event-stream,并建议设置 Cache-Control: no-cache。
FastAPI 流式对话接口
python
import json
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import httpx
app = FastAPI()
async def sse_stream(user_messages: list[dict]):
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream(
"POST",
"https://api.example.com/v1/chat/completions",
json={
"model": "gpt-4o-mini",
"messages": user_messages,
"stream": True,
},
) as resp:
async for line in resp.aiter_lines():
if not line.startswith("data: "):
continue
data = line[6:]
if data == "[DONE]":
break
chunk = json.loads(data)
delta = chunk["choices"][0]["delta"].get("content", "")
if delta:
yield f"data: {json.dumps({'type': 'text', 'text': delta})}\n\n"
yield f"data: {json.dumps({'type': 'done'})}\n\n"
@app.post("/api/chat/stream")
async def chat_stream(body: dict):
return StreamingResponse(
sse_stream(body["messages"]),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
X-Accel-Buffering: no 用于关闭 Nginx 对 SSE 的缓冲,否则前端可能直到流结束才一次性收到全部内容。前端用 EventSource 或 fetch + ReadableStream 逐事件解析,即可实现逐字显示。
模块 7:ReAct 与 Function Calling------手写 200 行内的 Agent(含多工具并发)
心智模型
Agent 的最小闭环是 LLM + Loop + Tools :模型输出 tool_calls → 本地执行工具 → 把结果以 tool 角色回注 → 再次调用模型,直到模型不再发起工具调用或触发硬终止条件。现代原生 Function Calling 替代了早期 ReAct 的"文本 Action 正则解析",但循环本身不变:谁执行工具、结果如何回到模型、循环何时停止,这些控制逻辑必须由你显式实现,框架只是把它藏了起来。
下面的实现不依赖任何 Agent 框架,覆盖:Pydantic 工具 schema、tool_calls 解析、多工具并发执行、异常回注、轮数终止、Token 统计。
python
import asyncio, json
from typing import Any, Callable
from pydantic import BaseModel, Field, ValidationError
from openai import AsyncOpenAI
client = AsyncOpenAI()
# ---------- 1. 工具定义 ----------
class GetAccountArgs(BaseModel):
phone: str = Field(..., description="客户手机号,11 位")
class SearchLeadsArgs(BaseModel):
keyword: str = Field(..., description="公司名称或关键词")
limit: int = Field(default=5, ge=1, le=20)
def get_account(phone: str) -> dict:
# 真实场景替换为数据库查询
return {"phone": phone, "name": "张三", "status": "已开户"}
def search_leads(keyword: str, limit: int = 5) -> list[dict]:
return [{"company": f"{keyword}科技有限公司", "stage": "线索"}][:limit]
TOOLS: dict[str, tuple[type[BaseModel], Callable]] = {
"get_account": (GetAccountArgs, get_account),
"search_leads": (SearchLeadsArgs, search_leads),
}
def to_openai_tool(name: str, cls: type[BaseModel], desc: str) -> dict:
schema = cls.model_json_schema()
schema.pop("title", None)
return {"type": "function", "function": {"name": name, "description": desc, "parameters": schema}}
TOOL_SPECS = [
to_openai_tool("get_account", GetAccountArgs, "按手机号查询开户线索"),
to_openai_tool("search_leads", SearchLeadsArgs, "按公司关键词搜索线索"),
]
# ---------- 2. 单工具执行(带异常回注) ----------
async def execute_one_tool(tc) -> dict:
name = tc.function.name
entry = TOOLS.get(name)
if entry is None:
return {"tool_call_id": tc.id, "role": "tool", "content": json.dumps({"error": f"unknown tool: {name}"}, ensure_ascii=False)}
cls, fn = entry
try:
args = cls(**json.loads(tc.function.arguments))
except (json.JSONDecodeError, ValidationError) as e:
return {"tool_call_id": tc.id, "role": "tool", "content": json.dumps({"error": f"invalid args: {e}"}, ensure_ascii=False)}
try:
result = await asyncio.to_thread(fn, **args.model_dump())
return {"tool_call_id": tc.id, "role": "tool", "content": json.dumps(result, ensure_ascii=False)}
except Exception as e:
return {"tool_call_id": tc.id, "role": "tool", "content": json.dumps({"error": f"tool failed: {e}"}, ensure_ascii=False)}
# ---------- 3. ReAct 主循环 ----------
async def run_agent(user_query: str, max_turns: int = 6) -> str:
messages = [
{"role": "system", "content": "你是线索查询助手。需要数据时调用工具;不要编造。"},
{"role": "user", "content": user_query},
]
for turn in range(max_turns):
resp = await client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=TOOL_SPECS,
tool_choice="auto",
temperature=0,
)
msg = resp.choices[0].message
if not msg.tool_calls:
return msg.content or ""
# 把 assistant 的 tool_calls 原样放回历史
messages.append({
"role": "assistant",
"content": msg.content,
"tool_calls": [tc.model_dump() for tc in msg.tool_calls],
})
# 关键:多工具并发执行,而不是 for 循环串行 await
tool_results = await asyncio.gather(
*[execute_one_tool(tc) for tc in msg.tool_calls]
)
messages.extend(tool_results)
# 硬终止:防止无限循环
return "已达到最大轮数,未能得到最终答案。"
# asyncio.run(run_agent("查一下 13800138000 的开户状态,并搜索'星辰'相关线索"))
这段代码中几个必须由你自己实现、框架不会替你保证的点:
tool_call_id配对 :每个tool消息的tool_call_id必须与 assistant 消息中对应tool_call.id严格一致,否则 API 返回 400。- 异常回注 :工具抛异常、参数校验失败、未知工具名,都不应该让循环崩溃,而应转成
{"error": ...}的tool消息交回模型,让它有机会自我纠正。 - 多工具并发 :
asyncio.gather让同一轮中的多个tool_calls并行执行;如果串行await,多工具场景的延迟会线性叠加。 - 硬终止 :
max_turns是防止模型陷入"调用---失败---再调用"死循环的最后防线。生产环境还应叠加时间预算和重复调用检测。 - 同步工具的桥接 :示例用
asyncio.to_thread(fn, ...)包装同步工具;如果你的工具本身是async def,直接在execute_one_tool中await fn(...)即可,不要多一层线程。
阶段产出检查清单
完成以上七个模块后,你的最小 Agent 应该具备:
- 并发调用多个 LLM 的能力,单个失败不影响整体;
- FastAPI 骨架带
/health/live、/health/ready、全局异常处理器、request_id 结构化日志; - tenacity 只对超时/429/5xx 做指数退避重试;
- Pydantic 工具模型自动生成 OpenAI Tool Schema,同时用于运行时参数校验;
- Token 计数器包含 message 格式开销,能按预算截断历史并估算成本;
- SSE 流式接口,前端可逐字接收;
- 手写 ReAct 循环,支持
tool_calls解析、多工具并发、异常回注、轮数硬终止。
把这七块分别跑通,再组合成一个"带日志 + Token 统计 + 流式输出"的 Agent,阶段一的目标就完成了。