Agent阶段一:基础夯实

下面按照你阶段一的七个模块,逐一给出可直接落地的知识点与代码实例。所有代码以 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 的开户状态,并搜索'星辰'相关线索"))

这段代码中几个必须由你自己实现、框架不会替你保证的点:

  1. tool_call_id 配对 :每个 tool 消息的 tool_call_id 必须与 assistant 消息中对应 tool_call.id 严格一致,否则 API 返回 400。
  2. 异常回注 :工具抛异常、参数校验失败、未知工具名,都不应该让循环崩溃,而应转成 {"error": ...} 的 tool 消息交回模型,让它有机会自我纠正。
  3. 多工具并发 :asyncio.gather 让同一轮中的多个 tool_calls 并行执行;如果串行 await,多工具场景的延迟会线性叠加。
  4. 硬终止 :max_turns 是防止模型陷入"调用---失败---再调用"死循环的最后防线。生产环境还应叠加时间预算和重复调用检测。
  5. 同步工具的桥接 :示例用 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,阶段一的目标就完成了。

相关推荐
估值探索者1 小时前
【Python量化技术指标实战 04】布林带通道突破,怎么知道是真突破还是假突破?
java·开发语言·python·接口·股票数据api接口
Data_AI4Science1 小时前
AI 时代,人更重要了
人工智能·ai·智能体·生物医药·电子实验记录本·ai取代人
fanruitian1 小时前
python property
开发语言·python
MounRiver_Studio1 小时前
原生集成AI 能力的 RISC-V IDE:第三代 MounRiver Studio 正式发布
ide·ai·agent·risc-v
Web3&Basketball1 小时前
MiniMax M3.1 Flash实战:3个成本坑
python·大模型·成本优化·minimax·推理优化·token计费
Angeldream1231 小时前
第 7 章 医学影像特征提取与分析
图像处理·python·深度学习·计算机视觉·医学影像·特征提取
艾莉丝努力练剑1 小时前
【AI大模型接入SDK】ChatServer 路由规则绑定
网络·c++·人工智能·学习·ai·架构
Harzerr1 小时前
AI行业日报|2026-10-22:5个热点事件
ai·行业动态·技术趋势
无语的小歪歪1 小时前
ATF 智测 1.0
python·测试工具·集成测试