一、为什么选 FastAPI
三个理由:
- 原生 async + SSE :Agent 要流式输出,FastAPI 的
StreamingResponse最好用 - 自带 OpenAPI :
/docs直接能调试,面试演示很方便 - Pydantic 校验:请求体自动校验,省一堆 if
二、目录即架构
bash
app/
├── config.py # 配置中心(唯一读环境变量的地方)
├── llm/
│ ├── providers.py # 模型适配:重试 + 降级 + 流式 + Mock
│ └── prompts.py # Prompt 模板(版本化管理)
├── rag/
│ ├── loader.py # 文档切分
│ ├── embedding.py # 向量化
│ ├── vectorstore.py # 向量库(memory / pgvector / chroma)
│ └── retriever.py # 检索编排
├── agent/
│ ├── state.py # AgentState
│ ├── graph.py # 编排
│ ├── tools.py # 工具
│ ├── guardrails.py # 护栏
│ └── memory.py # 记忆
├── observability/tracer.py # 可观测
├── api/routes.py
└── main.py
分层原则 :llm / rag / agent 三层彼此不知道 HTTP 的存在, api 层只做协议转换。好处是------换成 Java 实现时,业务逻辑是一模一样的(见第 09 篇)。
三、配置中心:一处读环境变量
python
# app/config.py
@dataclass
class Settings:
llm_provider: str = "openai"
llm_base_url: str = "https://api.deepseek.com/v1"
...
@classmethod
def from_env(cls) -> "Settings": ...
settings = Settings.from_env()
三个细节:
- 自动加载
.env:
python
from dotenv import load_dotenv
_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
load_dotenv(os.path.join(_BASE_DIR, ".env"), override=False)
-
自动降级 :没配 Key 时
llm_provider自动从openai变mock,embed_provider自动从openai变hashing------保证永远不会因为缺配置而启动失败。 -
全环境变量可覆盖:Docker / K8s 里直接注入 env,不用改代码。
四、LLM 适配层:屏蔽厂商差异
python
class BaseLLM:
def chat(self, messages, *, temperature=None, json_mode=False, trace_id="", span_name="llm"): ...
def stream(self, messages, *, temperature=None, trace_id=""): ...
OpenAICompatibleLLM 走 OpenAI SDK,DeepSeek / Qwen / 通义 / vLLM / Ollama 全兼容 (它们都实现了 /v1/chat/completions)。
重试与主备降级
python
candidates = [self.model] + self.fallback_models # 主模型 + 备用模型
for model in candidates:
for attempt in range(self.max_retries + 1): # 指数退避 0.6s / 1.2s
try:
return self._client.chat.completions.create(**kwargs)
except Exception as e:
if attempt < self.max_retries:
time.sleep(0.6 * (2 ** attempt))
为什么要主备降级 :线上遇到过主模型限流/故障,整个 Agent 就不可用了。 配一个 LLM_FALLBACK_MODELS=qwen-plus,deepseek-chat 就能自动切。
Token 统计
每次调用把 usage 上报 Tracer:
python
tracer.record_llm(trace_id, u["prompt"], u["completion"], model=resp.model)
前端右上角实时显示累计 token------成本可见,才谈得上治理。
五、Prompt 模板:五段式 + 集中管理
python
PLANNER_TMPL = """{system_role}
【可用工具】
{tools}
【历史对话摘要】
{summary}
【用户问题】
{question}
【任务】
把问题拆解为 1~3 个可执行步骤......严格输出 JSON......"
"""
我把所有模板放在 llm/prompts.py,理由:
- 可版本化:改 prompt 不改业务代码,能灰度、能 A/B
- 可测试:把模板抽出来后,能对同一模板做回归
- 可复用:Python 侧和 Java 侧用的是同一套模板文本
五段式结构:角色 → 目标 → 约束 → 输出格式 → 少样本示例。 规划节点强制输出 JSON,并配了容错解析器(去 markdown 代码块、截取首个 JSON 对象):
python
def parse_json_safe(text):
m = re.search(r"```(?:json)?\s*(.*?)```", text, re.S) # 去代码块
...
start, end = raw.find("{"), raw.rfind("}") # 截取 JSON
模型偶尔会输出 json ... ,这个解析器救过很多次。
六、API 设计
| 接口 | 方法 | 说明 |
|---|---|---|
/api/chat |
POST | SSE 流式对话 |
/api/chat/sync |
POST | 非流式(curl / 定时任务 / 单测) |
/api/knowledge/ingest |
POST | 知识库重建索引 |
/api/knowledge/search |
POST | 检索调试 |
/api/traces、/api/traces/{id} |
GET | Trace 查询 |
/api/tools |
GET | 工具清单(暴露给前端展示) |
/api/sessions/{id} |
GET/DELETE | 会话记忆 |
SSE 事件类型:
java
trace_start / plan / tool_start / tool_end / reflect / hitl
token(增量文本) / llm_usage / final / error / stream_end
七、一个踩坑:同步图 + async 服务会阻塞事件循环
LangGraph 的 invoke 是同步 的,直接在 async def 里调用会把整个事件循环卡死。
解法:线程池跑图 + 线程安全回传事件
python
def worker():
for event in runner.run(query, sink=sink):
loop.call_soon_threadsafe(queue.put_nowait, event)
asyncio.get_running_loop().run_in_executor(None, worker)
sink 用 loop.call_soon_threadsafe 把事件从工作线程推回主线程的 asyncio.Queue。 这样多个用户同时提问也不会互相阻塞。