很多人开始做AI智能体对话平台的时候,都容易走反方向:一上来就让模型开口说话。我第一次搭这类项目也是这样,找一套大模型服务的示例代码,改巴改巴跑通了一次对话,兴奋劲儿刚过就发现,这只是一个会回话的脚本,离"平台"还差着一个完整的地基。
AI智能体对话平台开发实战里最磨人的,往往不是想什么酷炫的 Agent 玩法,而是那些看起来平平无奇的地基:项目怎么组织、配置放哪里、会话和消息怎么建模、接口怎么把上下文喂给模型、出错以后怎么定位。这些东西如果第一周不弄明白,后面每加一个功能都要回头拆墙。
这篇是系列第二节,主题就是把地基完整搭起来——从一台只装了 Python 的空机器,到一个能跑通真实对话的最小平台,并且把每一步背后的"为什么"讲清楚。适合刚准备动手做同类项目的人,也适合已经写了大量示例代码、现在想回头整理结构的老手。
1. "地基搭建"到底在搭什么:先别急着让模型开口
1.1 Demo 后遗症:跑通了反而更难受
先讲一个很常见的场景,我估计不少人都经历过。项目刚开始的时候,图快,把代码全堆在一个文件里,凭记忆拼出一次模型调用,curl 一发,有回复了,然后就开始在上面继续加功能。加着加着,问题会像连环雷一样冒出来——上下文整个存在内存列表里,服务一重启,用户的历史对话全部蒸发;密钥直接写在文件顶部,某次把代码发给别人看或者提交到仓库的时候,顺手就把密钥透传出去了;模型名、温度这些参数散落在各处,想换一套模型得全文搜索替换;接口一报错,前端收到的是完整的调用栈,用户看不明白,你自己也懒得翻日志。
这些现象本质上是同一个问题:地基没有做。地基这个词听起来很虚,但落到代码上就是几个具体的决策和文件。这一节先把它们拆开,后面几节逐项落地。
1.2 地基的四块砖:骨架、配置、数据、链路
我给"地基"划了个很具体的范围,一共四层:
- 可运行的服务骨架。有明确的启动入口、路由组织方式、目录分层,能让人一眼看出"这个项目怎么跑起来、新功能往哪里塞"。
- 环境与配置管理。密钥、模型服务地址、模型名、数据库地址全部走配置,不写死在代码里,环境不同就换一份配置。
- 核心数据模型。会话、消息、智能体这三张表的关系一开始就定清楚,别等对话记录没法查的时候再重构。
- 一条最小但完整的对话链路。用户发一句话,系统带着历史上下文去调模型,模型返回后再落库,整条链路闭环。
1.3 这篇的边界:哪些事这次不做
既然是"地基",就要克制。这次我不会去做工具调用、多智能体路由、会话上下文压缩、流式输出这些进阶功能——它们都是下一篇或者后面几篇的主角。如果现在就把这些复杂度塞进门槛,第一行代码根本落不了地。
但这不意味着后路不管。地基的每一块砖都会留出接口:模型接入做成薄的封装,以后换服务商不伤筋动骨;消息表单独建,以后做记忆、做压缩、做审计都有地方挂;路由目录按模块分,以后加功能只是往里添文件。先跑通再长胖,但跑通之前想清楚它会长成什么样。
2. 技术选型:四个决定未来半年开发体验的选择
2.1 语言与框架:为什么是 Python + FastAPI
选 Python 基本没什么悬念。模型服务商的 SDK、示例代码、开源生态,几乎全是 Python 优先,写 AI 相关业务能省掉大量踩坑时间。版本上用 3.11 起步,类型标注和性能都够用。
框架我直接说结论:FastAPI。理由不是它自动生成接口文档好看那么简单,核心原因是异步。一次对话要等大模型输出,这个等待时间在网络上常常是十几秒到几十秒,同步阻塞模式下,一个慢请求会把整个服务卡住,并发一上来就麻了。FastAPI 基于异步 Web 框架,处理这种高等待、低计算密度的场景天然合适。再加上它和 Pydantic 深度绑定,请求参数校验、响应模型、配置模型用的都是同一套类型系统,少学一个概念。
有人会问为什么不选 Django。Django 的 admin、ORM、中间件生态确实强,但它是同步模型,硬上异步需要额外封装,对对话平台这种模式比较简单的后端,属于杀鸡用了牛刀。Flask 则太自由,项目一旦变多文件,路由和依赖注入全靠自己约定,团队协作时风格容易乱。
2.2 模型接入:不绑定任何一家大模型服务商
这是地基里最容易犯的错。很多示例代码直接用了某个服务商封装的 SDK,看起来省事,实际上把服务和某一家厂商的接口细节耦合在了一起,后面想换或者想本地部署开源模型,得重写业务代码。
我的做法是只依赖一个大模型行业内普遍兼容的接入协议,自己封装一个极薄的客户端。请求体结构大体上是三样:模型名、消息列表、温度等生成参数;响应结构里拿聊天内容也是固定路径。把这个协议封装在 app/llm_client.py 这个文件里,业务代码只调用chat(messages, model, temperature),至于背后是哪个服务商,由配置文件的 base_url 和 api_key 决定。
这样一来,切换模型服务商这件事,从"改业务代码"降级成"改环境变量",地基期多花二十分钟,后面能省出无数个下午。
2.3 存储:SQLite 起步,SQLAlchemy 留后路
对话数据要不要一开始就用 PostgreSQL?我的建议是不要。开发期最重要的目标是快速迭代,SQLite 零配置、单文件、随手可备份,足够支撑开发和单人测试场景。真正的坑是:因为图省事直接写 SQLite 专有的操作方式,把代码和存储引擎绑死。
解决绑定问题用 SQLAlchemy 2.0 的 ORM。sqlite 和 postgresql 之间的切换,绝大多数场景只需要改一行 database_url 配置。所以选型策略是:存储引擎可以先低配,数据访问层必须按可迁移的规格来写。模型定义里尽量少用某个数据库专有的字段类型,用标准的 String、Text、DateTime,以后搬家不折腾。
2.4 一张决策表,把这次的选择定死
| 决策点 | 我选的东西 | 替代方案 | 为什么这么选 |
|---|---|---|---|
| 编程语言 | Python 3.11+ | Go、Node.js | 模型生态 Python 优先,类型和异步都够用 |
| Web 框架 | FastAPI | Flask、Django | 原生异步、Pydantic 集成、自动接口文档 |
| 数据访问 | SQLAlchemy 2.0 | raw SQL、Tortoise | 后续可平滑切到 PostgreSQL |
| 出站 HTTP | httpx | requests、aiohttp | 异步风格统一,也能做同步调用 |
| 配置管理 | pydantic-settings | os.environ 裸读 | 自动类型转换,一个类管所有配置 |
| 依赖管理 | uv | poetry、pip + venv | 安装快、锁文件可靠、环境隔离顺手 |
这张表就是地基的"宪法",以后加依赖之前先对照一下,别随口安装。
3. 从空目录到可运行服务:项目骨架的落地全过程
3.1 目录结构:每一层放什么,边界是什么
建目录这件事,看起来只是 mkdir,实际上是在给未来的自己划地盘。我最终用的是下面这个结构,比较克制,但每一层的职责都清楚:
agent-platform/ ├── app/ │ ├── __init__.py │ ├── main.py # 应用入口,注册路由和启动事件 │ ├── config.py # 全局配置 │ ├── database.py # 数据库引擎和会话管理 │ ├── models.py # ORM 模型 │ ├── schemas.py # 请求/响应模型 │ ├── llm_client.py # 大模型接入客户端 │ └── routes/ │ ├── __init__.py │ └── dialogue.py # 对话相关接口 ├── .env.example # 配置模板,入库 ├── .gitignore └── pyproject.toml注意一个原则:路由里不放业务逻辑,模型文件里不放接口字段定义。请求进来,路由层负责取参数、调服务、组织响应;模型层只负责表结构;schema 层管数据校验。这个分层在项目小时看起来有些冗余,但它是后面所有功能的地基,尤其是多人协作的时候,没有边界就是互相踩脚。
3.2 初始化动作:依赖、配置模板、忽略文件
项目目录建好后,先做依赖初始化。我用 uv 管理,几个命令就完成了:
cd agent-platform uv init --name agent-platform --python 3.11 uv add fastapi "uvicorn[standard]" sqlalchemy pydantic pydantic-settings httpx如果你习惯 pip,等价的做法是创建虚拟环境后把同样的包名装进去。uv 只是让这个过程更快,选哪个取决于你的习惯,不要为了工具再开一条学习曲线。
接下来写.env.example,一个配置模板,它的作用是告诉后续接手的人:这个项目需要哪些环境变量,每个变量长什么样。
# .env.example DATABASE_URL=sqlite:///./agent_platform.db LLM_BASE_URL=https://api.example-llm.com/v1 LLM_API_KEY= LLM_MODEL=your-model-name然后.gitignore里写上.env、.venv/、__pycache__/、*.db这几行。这两件事一起做,比任何安全培训都有效。
3.3 第一行代码:让环境先亮起来
我说过这篇的标题是"从零开始写第一行代码",那这行代码到底是什么?我建议不是模型调用,而是一个健康检查接口。因为地基阶段第一优先的验证目标是:服务能起来、配置能读、依赖能导入。
先写配置文件,用 pydantic-settings 把环境变量变成类型化配置:
from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8") app_name: str = "agent-platform" database_url: str = "sqlite:///./agent_platform.db" llm_base_url: str = "" llm_api_key: str = "" llm_model: str = "" settings = Settings()再看 app/main.py,这才是名副其实的"第一行代码":
from fastapi import FastAPI app = FastAPI(title="Agent Dialogue Platform", version="0.1.0") @app.get("/health") async def health(): return {"status": "ok"}启动服务:
uv run uvicorn app.main:app --reload --port 8000浏览器打开或者 curl 一下 http://127.0.0.1:8000/health ,看到{"status":"ok"},地基的第一盏灯就亮了。
3.4 验证骨架的几个标准
别急着往下写,先花两分钟验收四件事。第一,配置文件能不能读到 .env 里的值,把 LLM_API_KEY 填上再打印 settings.llm_api_key,确认非空;第二,--reload模式下改代码能自动重载;第三,访问 http://127.0.0.1:8000/docs 能看到自动生成的接口文档,说明 FastAPI 的自省能力正常;第四,端口被占用时切换端口的命令要顺手,uvicorn app.main:app --port 8001这种基础操作不用查文档。
这四件事过了,项目骨架才算真的立住。很多人跳过这步直接往下写数据模型,后面遇到"为什么代码改了不生效""为什么密钥读不到"这种问题,再回头排查,反而更耗时。
4. 数据模型先行:会话、消息和智能体之间的关系设计
4.1 三个核心实体,各管一摊
对话平台的表结构,第一版我建议就三张:agents、conversations、messages。它们的关系一句话说清:一个智能体可以开多个会话,一个会话包含多条消息。
- agents 存"智能体"的静态配置:名字、系统提示词、默认使用的模型名、温度参数。把这些从对话代码里抽出来,是因为以后你可能要做多个角色不同的智能体,把配置放表里,以后做管理界面、做切换路由,都有了落脚点。
- conversations 存"会话":属于哪个智能体、标题是什么、什么时候创建的。会话是用户每一次对话的容器,也是上下文隔离的单位。
- messages 存"消息":属于哪个会话、是谁说的、内容是什么、什么时候说的。消息是流水记录,它的顺序就是喂给模型的上下文顺序。
4.2 为什么消息要单独建表,而不是存一个数组
新手最容易犯的错,是在 conversations 表里放一个 text 字段,把整个历史对话序列化成一个 JSON 字符串塞进去。第一周确实够用,但后面问题会接踵而至:想查某条消息的内容得先反序列化整段历史;想统计用户说了多少话、模型回复了多少字,无从下手;想实现消息级别的操作(比如删除某条不当内容)更是折磨。
消息单独建表,本质是把"流水"和"容器"分开。对话是不断追加的记录流,会话是它们的归属地,这两件事混在一张表里,是后续所有上下文相关功能的隐患。代价只是多写几句外键关系,但换来的是对每一句话的完整掌控。
4.3 用 SQLAlchemy 2.0 把模型写出来
先写 database.py,统一管理引擎和会话:
from sqlalchemy import create_engine from sqlalchemy.orm import DeclarativeBase, sessionmaker from .config import settings connect_args = {} if settings.database_url.startswith("sqlite"): connect_args = {"check_same_thread": False} engine = create_engine(settings.database_url, connect_args=connect_args) SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False) class Base(DeclarativeBase): passsqlite 的check_same_thread: False是让多线程环境下也能访问同一个数据库连接,本地开发必备。
然后是 models.py,用 SQLAlchemy 2.0 的 Mapped 风格定义:
from datetime import datetime, timezone from sqlalchemy import DateTime, ForeignKey, String, Text from sqlalchemy.orm import Mapped, mapped_column, relationship from .database import Base def utcnow() -> datetime: return datetime.now(timezone.utc) class Agent(Base): __tablename__ = "agents" id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] = mapped_column(String(50), nullable=False) system_prompt: Mapped[str] = mapped_column(Text, default="") model_name: Mapped[str] = mapped_column(String(50), default="") temperature: Mapped[float] = mapped_column(default=0.7) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) class Conversation(Base): __tablename__ = "conversations" id: Mapped[int] = mapped_column(primary_key=True) agent_id: Mapped[int] = mapped_column(ForeignKey("agents.id"), index=True) title: Mapped[str] = mapped_column(String(100), default="新对话") created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) agent: Mapped["Agent"] = relationship() messages: Mapped[list["Message"]] = relationship(back_populates="conversation") class Message(Base): __tablename__ = "messages" id: Mapped[int] = mapped_column(primary_key=True) conversation_id: Mapped[int] = mapped_column(ForeignKey("conversations.id"), index=True) role: Mapped[str] = mapped_column(String(20)) content: Mapped[str] = mapped_column(Text) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True) conversation: Mapped["Conversation"] = relationship(back_populates="messages")role 字段目前只有 system、user、assistant 三种值,我特意没用数据库级别的枚举,因为后续如果接入工具调用,消息类型会扩展,字符串字段给后期留了余地。content 用 Text 而不是 String,因为对话内容没有理由限定长度,多轮回复拼起来可能很长。
4.4 建表的时机与迁移的债
表结构定义好了,什么时候真正建表?地基阶段我在应用启动事件里做。把 main.py 更新为:
from contextlib import asynccontextmanager from fastapi import FastAPI from .database import Base, engine from .models import Agent, Conversation, Message # 导入即注册,create_all 才知道建哪些表 @asynccontextmanager async def lifespan(app: FastAPI): Base.metadata.create_all(bind=engine) yield app = FastAPI(title="Agent Dialogue Platform", version="0.1.0", lifespan=lifespan)create_all只建不存在的表,不会改已有的表。这个阶段够用,但心里要清楚它是一笔"债务"——等表结构开始频繁改动,就要引入迁移工具来管理版本。地基期先不做迁移工具,是因为表结构不会大动,等第三次结构调整时自然就换过去了。
另外一个建议:每张表都带上 created_at,时间统一用 UTC。对话平台的审计、统计、上下文顺序都依赖时间字段,现在嫌麻烦不写,后面补数据比写代码痛苦得多。
5. 第一个真正能对话的接口:从请求到落库的完整链路
5.1 一次对话请求流过哪些节点
到这一节,终于要把对话链路串起来了。用户发一条消息到 POST /api/v1/conversations/{id}/messages,系统要依次做五件事:
- 校验请求参数,取出会话 ID 和用户消息内容;
- 把用户消息写入 messages 表;
- 按时间顺序查出该会话的全部历史消息;
- 组装上下文——系统提示词放最前,然后是完整历史,最后是这条新消息——调大模型接口拿到回复;
- 把回复写入 messages 表,返回给前端。
这个顺序有一个关键细节:用户消息必须先落库,再拿去调模型。否则如果模型调用成功但服务随后崩溃,用户的这句话就丢了,后端与模型之间的一致性无从谈起。
5.2 写一个不绑厂商的 LLM 客户端
大模型接入做成一个薄薄的类,所有厂商差异都被关在这个文件里:
import httpx class LLMClient: def __init__(self, base_url: str, api_key: str): self.base_url = base_url.rstrip("/") self.api_key = api_key async def chat(self, messages: list[dict], model: str, temperature: float = 0.7) -> str: url = f"{self.base_url}/chat/completions" headers = {"Authorization": f"Bearer {self.api_key}"} payload = { "model": model, "messages": messages, "temperature": temperature, } async with httpx.AsyncClient(timeout=60.0) as client: response = await client.post(url, headers=headers, json=payload) response.raise_for_status() data = response.json() return data["choices"][0]["message"]["content"]代码很少,但三个点值得说。第一,用 httpx 而不是 requests,因为要保持 async 风格统一,同步和异步场景都能用。第二,超时设了 60 秒,大模型接口慢是常态,预设太短会频繁误报。第三,只要服务商兼容主流对话补全协议,换厂商只需要改 base_url 和 api_key,业务代码对"换了模型"这件事毫无感知。
5.3 对话接口的实现
先定义请求和响应 schema,用 Pydantic 卡住入参边界:
from pydantic import BaseModel, Field class MessageCreate(BaseModel): content: str = Field(min_length=1, max_length=16000) class MessageResult(BaseModel): role: str = "assistant" content: str然后是核心的对话路由,完整代码在 app/routes/dialogue.py:
from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import select from sqlalchemy.orm import Session from ..config import settings from ..database import SessionLocal from ..llm_client import llm_client from ..models import Agent, Conversation, Message from ..schemas import MessageCreate, MessageResult router = APIRouter(prefix="/api/v1", tags=["dialogue"]) def get_db(): db = SessionLocal() try: yield db finally: db.close() @router.post("/conversations", status_code=201) def create_conversation(db: Session = Depends(get_db)): agent = db.scalar(select(Agent).limit(1)) if agent is None: raise HTTPException(status_code=400, detail="还没有可用智能体,请先创建") conversation = Conversation(agent_id=agent.id) db.add(conversation) db.commit() db.refresh(conversation) return {"id": conversation.id, "agent_id": conversation.agent_id} @router.post("/conversations/{conversation_id}/messages", response_model=MessageResult) async def send_message(conversation_id: int, payload: MessageCreate, db: Session = Depends(get_db)): conversation = db.get(Conversation, conversation_id) if conversation is None: raise HTTPException(status_code=404, detail="会话不存在") db.add(Message(conversation_id=conversation.id, role="user", content=payload.content)) db.commit() history = db.scalars( select(Message) .where(Message.conversation_id == conversation.id) .order_by(Message.created_at.asc()) ).all() messages = [] if conversation.agent and conversation.agent.system_prompt: messages.append({"role": "system", "content": conversation.agent.system_prompt}) messages.extend({"role": m.role, "content": m.content} for m in history) model_name = settings.llm_model or (conversation.agent.model_name if conversation.agent else "") if not model_name: raise HTTPException(status_code=500, detail="未配置模型名称") try: reply = await llm_client.chat( messages, model=model_name, temperature=conversation.agent.temperature if conversation.agent else 0.7, ) except Exception: raise HTTPException(status_code=502, detail="模型服务暂时不可用,请稍后重试") db.add(Message(conversation_id=conversation.id, role="assistant", content=reply)) db.commit() return MessageResult(content=reply)细看会发现,我调模型前先把用户消息 commit 了,调完模型又把助手消息落库。这两次写库不能合并,因为中间隔着一次不确定的模型调用。如果模型失败,用户消息已经存在,接口返回 502,前端可以提示重试,至少不会出现"用户说了一句话但数据库里没记录"的可怕一致性问题。
还需要在 main.py 里把路由挂上去:
from .routes.dialogue import router app = FastAPI(title="Agent Dialogue Platform", version="0.1.0", lifespan=lifespan) app.include_router(router)5.4 用 curl 真实跑一轮
启动服务后,先建一个会话:
curl -X POST http://127.0.0.1:8000/api/v1/conversations返回类似{"id":1,"agent_id":1}。然后发消息:
curl -X POST http://127.0.0.1:8000/api/v1/conversations/1/messages \ -H "Content-Type: application/json" \ -d '{"content":"你好,请用一句话介绍你自己"}'第一次调用可能等几秒到十几秒,这是正常的。返回的结构类似:
{"role": "assistant", "content": "你好,我是一个由大模型驱动的对话助手,可以陪你聊天并帮你处理问题。"}这一步看到真实回复,"地基搭建"才算真正完成了闭环。你再发第二条消息,模型就能根据第一条的上下文回答了——因为每一次请求都会带上该会话的全部历史消息。这也验证了数据模型的设计:历史是从 messages 表里按顺序读出来的,不是写在代码里的局部变量。
6. 接口通了以后,马上补的三块短板
6.1 错误处理:别让堆栈裸奔到前端
第一次用 curl 调通是快乐的,但快乐通常维持不了多久,因为下一秒你就会开始测试异常路径。模型超时、网络抖动、请求被限流、接口返回错误格式,这些在你上线前总会遇到。
现在这个版本的问题在于,异常一旦裸抛,FastAPI 会返回 500,前端收到一大串堆栈。用户看不懂,也不该看。我的处理办法是:在 llm_client 里把非预期的错误统一包装成自定义异常,路由层只对自定义异常做捕获,对外返回统一的错误体。前面代码里已经用 except 兜了一层,后续可以把它抽成一个自定义异常类,加一个统一的异常处理器,让所有模型调用失败都返回同一个格式:状态码 502,消息"模型服务暂时不可用"。
这里有个实用的取舍:超时和限流其实值得做一次简单重试,但地基阶段我建议先不做自动重试。原因很简单,对话场景里用户能接受"失败以后手动再发一次",自动重试引入的重复消息风险比它带来的收益大。等做批量或者定时任务场景时,再单独设计重试机制。
6.2 密钥进 .env,连垃圾桶都不收
这个坑我见过太多次了:api_key 直接写在代码里,然后一不留神提交到仓库,配置外泄,账单爆炸。地基期就把规矩立住:任何密钥不进代码文件,只进环境变量和 .env,而 .env 文件从第一天起就在 .gitignore 里。
.env.example只保留字段名和占位符,比如LLM_API_KEY=,给人看格式但不给真实值。收到新机器或者同事克隆仓库,第一步是cp .env.example .env然后填自己的真实配置。这样密钥的流转路径是受控的,而不是跟着源代码到处传。
配置读取还有个次序问题要清楚:pydantic-settings 的优先级是"真实环境变量 > .env 文件 > 代码里的默认值"。所以生产环境把密钥放在部署平台的环境变量里,本地开发放在 .env 里,两者互不干扰,代码一行都不用改。
6.3 结构化日志:让每个请求有迹可循
前端再来找你对账的时候,你会感谢日志。地基阶段的日志要求不用高,但至少每一条请求要能回答四个问题:调的什么接口、哪个会话产生、模型调用花了多久、成功还是失败。
我用标准 logging 就能满足,关键是输出格式。别只打印字符串,尽量打成结构化的一行,将来接日志收集平台时直接按字段检索:
ts=2025-01-15T10:23:45Z level=INFO event=message_sent conversation_id=1 role=user ts=2025-01-15T10:24:05Z level=INFO event=llm_call conversation_id=1 latency_ms=20300 model=your-model-name顺手还能把模型接口返回的 token 用量记录下来,这是以后做成本统计和配额管理的原始数据。我建议在 llm_client 的返回里把 usage 一并带出来,路由层负责写日志,模型层不碰日志逻辑,职责又分了一层。
6.4 留一道简单的门:访问鉴权
对话平台是面向用户的,但地基期可以先不搞复杂的用户体系,不过也不能裸奔。一个成本最低的方案是加一道 API Key 校验:请求头里带 X-API-Key,和配置里的值一致才放行。
FastAPI 里用一个依赖实现,挂在路由上:
from fastapi import Header, HTTPException from ..config import settings def require_api_key(x_api_key: str = Header(default="")) -> None: if settings.api_key and x_api_key != settings.api_key: raise HTTPException(status_code=401, detail="unauthorized")health 接口不挂这个依赖,方便运维探测。这只是一个过渡方案,等真上了用户体系,再把它换成登录态和权限校验。地基阶段留一道门,是为了防止自己在开发过程中把带着真实数据的服务暴露到公网。
这三块短板补齐之后,这套地基基本就合格了。它现在还很朴素,没有工具调用,没有多智能体,没有流式对话,但它的骨架、数据、配置、链路、日志、鉴权都立住了,后面任何高级功能都不是在废墟上打补丁,而是往这套结构里加房间。
我个人在两三个项目上反复打磨过这套地基,最深的体会是:第一次敲下的那行app = FastAPI(...)看起来平平无奇,但它把一堆悬而未决的决策变成了一台能跑起来的机器。后面每次加新功能,回到 models 和 llm_client 的次数屈指可数,大部分时间都在 routes 层写代码。这就是地基该有的样子——前期多花一小时理清边界,后面少熬不知道多少个通宵。下一篇可以放心去聊工具调用了。