跳到主要内容

7 篇博文 含有标签「FastAPI」

查看所有标签

SQLAlchemy 模型加了字段,线上报列不存在?Alembic 迁移三个坑

· 阅读需 7 分钟

在 FastAPI + 异步 SQLAlchemy 项目里给 ORM 模型加了字段,本地跑通,部署上线后接口一调用就报 sqlalchemy.exc.ProgrammingError: column "xxx" does not exist——模型明明改了,表却没变。

在为客户构建 AI Agent SaaS 平台时遇到此问题,记录根因与解法。

TL;DR​

模型改了表没变(或迁移命令根本跑不起来),通常是三个坑之一:

  1. async 项目里 Alembic 还在用 async 引擎——Alembic 不在事件循环里运行,env.py 必须切到 sync URL(postgresql+asyncpg:// 换成 postgresql://,驱动用 psycopg2)
  2. down_revision 引用了链上不存在的 revision ID——alembic upgrade 直接报 Can't locate revision
  3. 只改了 ORM 模型,没生成迁移——代码层看着对,表结构纹丝不动,线上第一次用到新字段就炸

三个坑依次对应「迁移跑不了」「链断了」「没生成迁移」,排查顺序也按这个来。

问题现象​

三种表现,对应三个不同的坑:

表现一:模型加字段后线上报错

sqlalchemy.exc.ProgrammingError: (psycopg2.errors.UndefinedColumn) column "risk_threshold" does not exist

本地开发环境正常,生产环境第一次查询就挂。

表现二:执行迁移命令直接失败

Can't locate revision identified by '001_initial'

alembic upgrade head 根本走不到你的新迁移。

表现三:autogenerate 说「没有变化」

Generating migration ...  (no changes in schema detected)

你确定改了模型,它却说 schema 没变——多半是 env.py 没把模型 import 进来,或者连的不是目标库。

根因一:Alembic 用了 async engine​

Alembic 的迁移脚本不运行在 asyncio 事件循环里。 应用层用 async SQLAlchemy(asyncpg 驱动)没问题,但 alembic 命令是同步执行的,env.py 里如果沿用应用的 async URL,要么直接报驱动错误,要么行为不可预期。

解法是让 env.py 单独用 sync 引擎:URL 从 postgresql+asyncpg:// 替换成 postgresql://,并安装 psycopg2 驱动。

pip install psycopg2-binary

alembic/env.py 关键配置(在 config 对象就绪后、engine_from_config 之前):

from sqlalchemy import pool, engine_from_config
from alembic import context

from app.config import settings
from app.database import Base
from app.models import * # noqa: F401,F403 - import all models

# Override sqlalchemy.url with sync URL for migrations
# Replace postgresql+asyncpg:// with postgresql:// for psycopg2
sync_url = settings.database_url.replace("postgresql+asyncpg://", "postgresql://")
config.set_main_option("sqlalchemy.url", sync_url)

两个细节:

  • settings.database_url 里的 async URL 替换成 sync URL 后,用 config.set_main_option() 覆盖 ini 里的配置,alembic.ini 里就不用维护第二份连接串
  • from app.models import * 这行不能省——autogenerate 靠它把所有模型注册进 Base.metadata,漏了就是上面「no changes in schema detected」的表现三

依赖上,psycopg2-binary 装在运行 alembic 的那个环境里。CI 或容器里单独跑迁移时,这个依赖容易被漏掉。

根因二:down_revision 引用断链​

Alembic 用 revision / down_revision 维护一条单向链,upgrade head 就是沿着这条链从当前版本走到链尾。down_revision 必须逐字符引用链上真实存在的 revision 标识,凭记忆填一个「差不多」的名字,链就断了。

一个真实仓库的版本链,alembic/versions/ 下三个文件:

# 001_initial.py
revision = '001_initial'
down_revision = None

# 002_rename_model_config_to_llm_config.py
revision = '002_rename_model_config'
down_revision = '001_initial'

# 003_add_cascade_delete.py
revision = '003'
down_revision = '002_rename_model_config'

alembic upgrade 报 Can't locate revision identified by 'xxx',就是某个 down_revision 指向的字符串在链上找不到——可能是手滑写了别的迁移的文件名,可能是抄了半截 ID。

排查动作固定两步:

# 1. 看真实链条(左列是 revision ID)
alembic history

# 2. 看数据库当前停在哪个版本
alembic current

把新迁移的 down_revision 改成 alembic history 输出里链尾的真实值,再 alembic upgrade head。Alembic 会从 alembic_version 表记录的位置续传,已执行过的迁移不会重跑。

alembic history 也建议用 --verbose 看:迁移一多,光靠文件名猜链路不可靠。

注意事项

多个开发者并行建分支各写迁移,两条分支的 down_revision 指向同一个父节点时,upgrade head 会报 multiple heads。用 alembic merge 生成合并迁移解决——我们的仓库里就留着一个 merge 迁移文件,专门缝合两条并行分支。出现 multiple heads 不是异常,拖着不 merge 才是。

根因三:改了模型,没建迁移​

最常见的误区:ORM 模型是代码层的声明,它不会自己改数据库。在模型类里加一个字段:

class Agent(Base):
__tablename__ = "agent_agents"
# ...
risk_threshold = Column(String, default="medium") # 新加的

这只是改了 Python 对象的定义。数据库里的 agent_agents 表纹丝不动,第一次查询到这个字段就是 column does not exist。之所以本地常常「看着正常」,是因为开发环境可能用 create_all() 建过表、或者 SQLite 内存库每次重建——这些路径都绕过了迁移,掩盖了问题。模型层另一个容易踩的坑是 Pydantic v2 的 ORM 模式变更,迁移要点见 Pydantic v2 ORM mode 迁移。

ORM 模型新增字段后的固定三步:

# 1. 生成迁移(autogenerate 对比模型与数据库的差异)
alembic revision --autogenerate -m "add risk_threshold to agent_agents"

# 2. 人工检查生成的迁移文件(autogenerate 会漏改列、误删表,必须过目)
# alembic/versions/xxxx_add_risk_threshold_to_agent_agents.py

# 3. 应用到数据库
alembic upgrade head

第 2 步不能省。autogenerate 只做差异对比,服务器默认值、约束名这类信息它推断不全,直接 upgrade 有风险。

验证迁移生效,直接看表结构:

psql -c "\d agent_agents"   # 确认新字段存在
alembic current # 确认版本停在链尾

排查顺序:三问快筛​

「模型改了表没变」类问题(也叫字段不存在、列不存在),按这三个问题筛,一两分钟定位:

  1. 迁移命令本身能跑吗? 不能 → 坑二,查 down_revision 链
  2. 能跑,但数据库里没这个字段? → 坑三,确认 autogenerate 生成过、upgrade head 执行过、alembic current 停在链尾
  3. alembic upgrade 连接都连不上或驱动报错? → 坑一,env.py 的 sync URL 配置

团队协作场景再多加一条纪律:模型变更和迁移文件放在同一个 commit 里。只提模型不提迁移,协作者拉下代码就是线上同款报错。

常见问题​

alembic 迁移数据库的命令是什么?​

生成迁移用 alembic revision --autogenerate -m "描述",应用到数据库用 alembic upgrade head,回滚一步用 alembic downgrade -1,查看版本链用 alembic history。改完 ORM 模型的标准动作就是 autogenerate 生成、upgrade head 应用这两步,缺一步表结构都不会变。

SQLAlchemy 创建数据库和表后,改模型为什么不生效?​

SQLAlchemy 模型只是代码层的声明,不会自动修改数据库,建表改表由 Alembic 迁移承担。即使本地用 create_all() 建过表,新字段也只存在于内存元数据里,生产 PostgreSQL 在第一次用到该字段时就报 ProgrammingError: column does not exist。模型变更必须同步生成迁移文件并执行 upgrade。

alembic upgrade 报 Can't locate revision 怎么办?​

这是 down_revision 引用了链上不存在的 revision 标识,常见于手写迁移时凭记忆填短名。先跑 alembic history 拿到真实 ID,把新迁移的 down_revision 改成链尾的真实值,再 alembic upgrade head。revision 与 down_revision 必须逐字符一致,差一个字符整条链就断。

CCLEE

独立开发者,24年电商行业实战经验,专注将AI能力落地于真实商业场景。

合作咨询

接口 422、参数 undefined?前后端契约对不上的四个坑

· 阅读需 7 分钟

前后端联调的三种经典场面:POST 请求返回 422;路由页面参数永远是 undefined;SSE 请求状态码 200,消息却静默无响应。症状各不相同,挖到底是同一类根因——前后端对接口的理解不一致。

在为客户构建 AI Agent SaaS 平台时遇到此问题,前后端分别开发,四个坑全踩了一遍,记录定位方法与预防纪律。

TL;DR​

契约不一致有四种形态,对应三类症状:

坑不一致点症状
字段名前端发 name,后端要 label422
字段类型前端发对象数组,后端收字符串数组422
路由参数路由定义 :id,组件取 agentIdundefined,功能静默失效
事件字段后端推 {"content": ...},前端检查 token200,界面无响应

预防只有一个纪律:动手写代码前,先对齐 Schema——字段名、类型、路由参数名,全部以后端 Schema(或双方共同确认的接口文档)为唯一来源。

坑一:字段名不一致 → 422​

最原始的坑。前端 TypeScript 接口定义创建 API Key 的入参用 name + key,后端 Pydantic Schema 期望的是 label + api_key:

// 前端以为的
interface CreateApiKeyInput {
name: string
key: string
}

# 后端定义的
class ApiKeyCreate(BaseModel):
label: str
api_key: str
POST /api/api-keys → 422 Unprocessable Entity

定位靠 422 响应体,别瞎猜。 FastAPI 的 422 会带 detail 数组,逐条写明缺哪个字段、为什么:

[
{ "loc": ["body", "label"], "msg": "Field required", "type": "missing" },
{ "loc": ["body", "api_key"], "msg": "Field required", "type": "missing" }
]

Field required = 请求体里根本没有这个字段 = 字段名对不上。浏览器 Network 面板展开响应,十秒钟定位。

坑二:字段类型不一致 → 422​

字段名对上了,类型也能埋雷。后端 Schema 声明 mcp_tools 是字符串数组,前端却发对象数组(每个对象带 tool_id 和 token_id,业务上确实需要绑定 OAuth Token):

// 前端发的
{ mcp_tools: [{ tool_id: "t1", token_id: "k1" }] }

# 后端收的
mcp_tools: List[str]
PATCH /api/agents/{id} → 422 Unprocessable Entity

这次 detail 里报的是 Input should be a valid string——类型不符。修法在后端 Schema 兼容两种形态(前端数据结构有业务理由,不该硬砍):

from typing import List, Union

class McpToolConfig(BaseModel):
tool_id: str
token_id: str | None = None

mcp_tools: List[Union[str, McpToolConfig]]

Union 让 Schema 同时接受字符串(只引用工具 ID)和对象(带绑定配置),Pydantic 按顺序尝试匹配。这个模式对「接口演进、前端先行」的场景通用:Schema 迁就真实业务形态,而不是反过来逼调用方削足适履。

坑三:useParams 参数名不一致 → undefined​

路由定义和组件取参各写各的,参数名差一个词:

// 路由定义
<Route path="/agents/:id/memory" element={<MemoryPage />} />

// 组件里
const { agentId } = useParams<{ agentId: string }>()
// agentId 永远是 undefined —— 路由里的参数名叫 id,不叫 agentId

页面不报错、请求状态码正常,就是功能静默失效——agentId 是 undefined,后续 API 调用全带上 undefined,或者干脆没发出去。

两条修法:

// 1. 参数名对齐路由定义
const { id } = useParams<{ id: string }>()

// 2. 想用别的变量名,解构重命名
const { id: agentId } = useParams<{ id: string }>()

这个坑的隐蔽性在 useParams<{ agentId: string }> 的泛型参数——TypeScript 不会校验泛型里的键名是否真的存在于路由定义,类型标注给了假的安心。

坑四:事件字段不一致 → 200 但静默失败​

SSE 流式响应里,后端推的事件数据是 {"content": "..."},前端判断逻辑检查的却是 token:

// 前端的事件判断
const isTokenEvent = (d: any) => 'token' in d // 永远 false

// 后端实际推的
data: {"content": "你好"}

网络面板里请求 200、数据流在动,界面上却一个字都不出现——判断条件永远不成立,事件被静默丢弃。这类坑比 422 更难排查:没有报错,只有「功能没发生」。

修法是事件判断对齐后端的实际数据结构:

const isTokenEvent = (d: any) => 'content' in d && !('type' in d)

SSE / WebSocket 这类「一个连接多种事件」的接口,建议在事件数据里带显式的 type 字段做分发,靠「有没有某字段」推断事件类型的写法,契约变更时就是静默失败的温床。

预防:一条纪律​

四个坑的共同起因:前后端各自凭想象写了对接口的那一半。预防不需要工具,就一条纪律——

新接口动手前,先对齐 Schema:字段名、类型、路由参数名、事件数据结构,逐项过。 后端 Pydantic Schema(或 OpenAPI 文档)是唯一来源,前端 TypeScript 接口照着它写;谁要改契约,先改 Schema、双方确认,再动代码。

联调时遇到症状,按这个顺序筛:422 → 展开响应体 detail 看 loc;undefined → 核对路由定义的 :param 名;200 但功能没发生 → 抓一份真实响应,逐字段对前端判断逻辑。

流式接口的服务端另有伴生坑(客户端断开导致的异常与资源泄漏),已在 FastAPI SSE 客户端断开报 CancelledError?生成器必须捕获并 re-raise 一文展开。

注意事项

前端 TypeScript 的接口类型(interface XxxInput)只是编译期的纸面约束,as any、第三方请求封装都能绕过它——类型对得上不代表字段对得上。契约校验的最终依据永远是与后端 Schema 的比对,不是前端类型不报红。

常见问题​

FastAPI 返回 422 Unprocessable Entity 怎么排查?​

422 的响应体里带 detail 数组,逐条写明哪个字段、什么原因——先用浏览器 Network 面板或 curl 展开它,九成的 422 是字段名与后端 Schema 不一致或字段类型不符。请求体里发 name 而后端 Schema 要 label,就是典型的字段名坑。

React Router 的 useParams 为什么取不到值?​

useParams 返回的键名由路由定义里的 :param 决定:路由是 /agents/:id,就只能用 useParams().id 取。解构时想换变量名用重命名语法 const { id: agentId } = useParams()。参数名对不上不会报错,只是安静地给你 undefined。

前后端字段名不一致怎么预防?​

把后端 Schema(Pydantic/Zod 都行)当成契约的唯一来源:新接口动手前先对齐字段名和类型,前端 TypeScript 接口从 Schema 生成或逐字段核对。接口评审时多问一句「请求体长什么样」,比联调时抓包省一个下午。

CCLEE

独立开发者,24年电商行业实战经验,专注将AI能力落地于真实商业场景。

合作咨询

Python 任务全标 failed 却不报错?try/except 吞掉了异常

· 阅读需 5 分钟

在 RAG 知识库项目中排查文档同步任务全部标记 failed 的静默故障,以下是完整排查过程。

TL;DR​

重构一个公共方法改了参数签名,但漏改了一个调用方。调用方按旧契约传参抛 TypeError,而这个调用被包在 try/except 里,异常被悄悄吞进 failed 计数——服务不崩溃、日志没有 ERROR,只有计数字段悄悄上涨。这类「静默故障」是最难查的 bug。两个解法:重构签名后 grep 所有调用方同步;except 块必须记日志或重抛,绝不静默吞掉。

用抽象类统一多搜索 API,错误返回而非抛异常

· 阅读需 5 分钟

在为客户构建 AI Agent 平台时遇到此问题:需要支持多个搜索提供商(Tavily、Serper、Brave、Bing),同时确保工具调用失败时不会中断 Agent 对话流程。

TL;DR​

  1. 定义 SearchProvider 抽象基类 + SearchResult 数据模型,统一接口和输出格式
  2. 每个提供商继承基类,实现 search() 方法,内部做响应字段映射
  3. 关键设计:错误时返回包含错误信息的 SearchResult 对象,而非抛异常