别再把整个数据库Schema塞给AI了:5个Token优化策略,帮你省下80%的API账单
引言:一个价值$3000的教训
去年底,我在优化公司一个200+张表的PostgreSQL数据库时,犯了一个每个程序员都会犯的错误。
我把完整的Schema(DDL dump)直接粘贴进ChatGPT的对话框,让它帮我分析索引优化方案。对话持续了整整三天,每次追问我都重新贴一遍完整的Schema。
直到月底收到OpenAI的账单——$3127。
我盯着那个数字愣了很久。一个200张表的DDL dump大约40,000 tokens,我发了约150次请求,仅Schema部分就消耗了600万tokens。而实际上,我真正需要的可能只是其中5张表的定义。
更讽刺的是,当我后来用更聪明的方式重新做这件事时,同样的优化任务,token消耗降到了原来的1/8,而生成SQL的准确率反而提升了。
这不是模型的问题,是我们给模型喂东西的方式出了问题。
在AI辅助开发成为标配的今天, “如何高效地向LLM传递数据库上下文” 已经从一个锦上添花的小技巧,变成了直接影响开发成本和系统可靠性的核心工程问题。今天这篇文章,我会把自己踩过的坑和从最新研究中提炼出的5个Token优化策略一次性讲透,每个策略都会给出可直接复用的代码或配置方案。
问题诊断:为什么“全量Schema”是反模式?
在讨论解决方案之前,我们需要先理解问题的本质。
Token成本随数据库规模线性增长,而非随问题复杂度增长。 一个240张表的数据库,无论你问“上月各品类营收”还是“用户表的主键是什么”,每次请求都要付出数万tokens的固定成本。这意味着你的API账单会和数据库规模成正比,而不是和业务需求成正比。
但成本只是问题的一半。无关的Schema信息会主动损害模型的生成质量。 当上下文中塞入大量相似表名(orders、orders_archive、orders_v2)和不相关列时,模型产生幻觉表引用和错误JOIN的概率会显著上升。BIRD和Spider的基准测试都证实了这一点:提供给模型的Schema中无关表和列越多,Text-to-SQL的准确率越低。
这就形成了一个荒谬的局面:你花了更多钱,得到了更差的结果。
更隐蔽的问题是Schema快照的时效性。有人三月把Schema粘进Prompt模板,五月数据库重命名了一列,Agent就会一直对着过时的Schema生成SQL,而失败看起来像是“模型能力不行”,实际上是上下文过期了。
理解了这三个问题——成本线性增长、准确率反向下降、时效性漂移——我们就能有针对性地设计解决方案了。
策略一:RAG驱动的动态Schema选择(核心方案)
这是我在那次$3000教训之后采用的核心方案,也是目前学术界和工业界公认的最有效手段。
核心思想很简单:不再把整个Schema塞给模型,而是根据用户问题的语义,在查询时动态检索出最相关的表和列。 这本质上就是Retrieval-Augmented Generation在数据库上下文场景的落地。
效果数据
在一项针对Firebird企业数据库的研究中,采用RAG+元数据字典的动态上下文窗口方法,平均减少了84.4%的处理tokens,且没有损失查询质量。另一项多模态混合检索的实验显示,结合向量、全文、元数据和标签的多维排序,Token消耗可减少超过96%。
实现方案
核心架构分两步:
第一步:构建Schema元数据索引。 将每张表的表名、注释、列信息、外键关系向量化存储。
import openai
from sqlalchemy import inspect
# 提取Schema元数据并向量化
def build_schema_index(engine, index_name="db_schema"):
inspector = inspect(engine)
documents = []
for table_name in inspector.get_table_names():
columns = inspector.get_columns(table_name)
fks = inspector.get_foreign_keys(table_name)
# 构建自然语言描述
col_desc = ", ".join([f"{c['name']}({c['type']})" for c in columns])
fk_desc = ", ".join([f"{fk['constrained_columns']}->{fk['referred_table']}" for fk in fks])
doc = f"表 {table_name}: 列有 {col_desc}。外键: {fk_desc}"
doc = f"表 {table_name}: 列有 {col_desc}。外键: {fk_desc}"
# 获取向量
embedding = openai.Embedding.create(input=doc, model="text-embedding-3-small")["data"][0]["embedding"]
documents.append({
"table": table_name, "text": doc, "embedding": embedding})
return documents
第二步:查询时检索相关表。
import numpy as np
def retrieve_relevant_schema(question, schema_index, top_k=5):
# 将问题向量化
q_emb = openai.Embedding.create(input=question, model="text-embedding-3-small")["data"][0]["embedding"]
# 计算相似度
scores = []
for doc in schema_index:
sim = np.dot(q_emb, doc["embedding"]) / (np.linalg.norm(q_emb) * np.linalg.norm(doc["embedding"]))
scores.append((sim, doc))
# 返回最相关的top_k张表
scores.sort(key=lambda x: x[0], reverse=True)
return "\n".join([doc["text"] for _, doc in scores[:top_k]])
进阶:两阶段检索。 先用语义检索找到候选表,再通过外键关系图扩展,自动纳入JOIN路径上必要的关联表。这样既能精准命中,又不会遗漏连接所需的中间表。
关键工程细节
Schema索引需要版本化。 每次迁移后自动重建索引,并在索引中携带版本号和时间戳。Agent在检索时先检查索引新鲜度,超过24小时自动触发刷新。
不要用LLM来做Schema压缩。 用LLM压缩Schema(如让GPT生成简洁的Schema描述)虽然可行,但每次压缩都需要额外的API调用,反而增加了成本和延迟。规则引擎+向量检索的组合在效率上完胜。
策略二:渐进式披露(Progressive Disclosure)——让Agent自己按需取Schema
如果说RAG是“系统替你选表”,那么渐进式披露就是“给Agent工具让它自己选”。
核心理念来自Anthropic推广的上下文管理技术:Agent不预先加载所有信息,而是通过工具调用按需获取。 采用三层架构——元数据层(表名清单)→ 核心内容层(表Schema)→ 详细资源层(列注释、样例数据)——Agent只在需要时逐层深入。
工作流程
第一次调用:获取表清单。
{
"tool": "get_table_inventory",
"result": {
"tables": ["users", "orders", "products", "payments", "categories"],
"total": 5
}
}
Agent看到这个清单后,判断“用户问的是订单相关的问题”,于是发起第二次调用。
第二次调用:获取指定表的详细Schema。
{
"tool": "get_table_schema",
"arguments": {
"tables": ["orders", "products"] },
"result": {
"orders": {
"columns": [
{
"name": "id", "type": "bigint", "pk": true},
{
"name": "user_id", "type": "bigint", "fk": "users.id"},
{
"name": "product_id", "type": "bigint", "fk": "products.id"},
{
"name": "amount", "type": "decimal(10,2)"},
{
"name": "created_at", "type": "timestamp"}
]
}
}
}
为什么这比一次性给Schema更好?
一位开发者的实测数据很有说服力:在240张表的Postgres数据库上,全量Schema dump每次请求约消耗数万tokens,而渐进式披露的第一次调用(表清单)只需要消耗表名的tokens——差了三个数量级。
更重要的是,Agent自主选择表的过程本身就是一种推理。当Agent主动决定“我需要orders和products表”时,它已经在理解问题与Schema之间的语义关系了,这比被动接收一堆无关Schema要有效得多。
实现要点
表清单只包含表名、行数概览、数据库类型,不包含任何列定义。这足以让Agent判断该看哪些表,但又不会消耗过多token。对于有权限控制的系统,还可以在清单层面做过滤——只返回当前用户有权限访问的表。
策略三:Schema压缩与序列化优化
即使你确实需要传递多张表的Schema,也有办法让同样的信息占用更少的token。
核心洞察:LLM输入序列化和文档交换序列化是根本不同的两个问题。 JSON的设计目标是自描述、人类可读、语言无关,但这些“优点”在LLM消费场景中恰恰变成了负担——每个字段名都要重复,每个记录都要加花括号和引号。
一项针对1000条IoT传感器数据的研究显示,JSON序列化消耗了约80,000 tokens,其中大部分花在了重复的字段名(device_id、temperature、timestamp各重复1000次)和结构化标点(花括号、方括号、引号、冒号)上。
方案A:列式记法(ONTO)
ONTO的核心思想是 “声明一次Schema,多次引用数据” :字段名只声明一次,值以管道符分隔,层级用缩进表示。
对比示例:
JSON格式(约80,000 tokens):
[
{
"device_id": "sensor_01", "temperature": 23.5, "timestamp": "2024-01-01T00:00:00Z"},
{
"device_id": "sensor_01", "temperature": 23.7, "timestamp": "2024-01-01T00:01:00Z"},
...
]
ONTO格式(约40,000 tokens):
device_id | temperature | timestamp
sensor_01 | 23.5 | 2024-01-01T00:00:00Z
sensor_01 | 23.7 | 2024-01-01T00:01:00Z
实测数据显示,ONTO相比JSON减少了46-51%的token,推理延迟降低5-10%,且LLM的理解准确率没有可测量的下降。
方案B:Schema压缩工具(Schemonic)
Cornell大学的研究者提出的Schemonic系统,将Schema压缩建模为组合优化问题,通过引入缩写和分组相似属性,自动生成简洁的Schema描述。在TPC-H、SPIDER和Public-BI上的实验表明,Schema描述长度显著减少,且Text-to-SQL准确率不降。
一个实用的简化版做法:用伪DDL替代完整DDL。完整DDL包含TABLESPACE、STORAGE、PCTFREE等对SQL生成毫无意义的存储子句,这些可以直接剔除。
-- 完整DDL(约500 tokens/表)
CREATE TABLE orders (
id BIGINT NOT NULL AUTO_INCREMENT,
user_id BIGINT NOT NULL,
amount DECIMAL(10,2) DEFAULT 0.00,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
INDEX idx_user (user_id),
ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
);
-- 压缩伪DDL(约150 tokens/表)
-- orders: id PK, user_id FK->users, amount dec, created_at ts
策略四:前缀缓存(Prompt Caching)——让重复部分不再计费
这是最容易被忽视但节省效果最直接的手段。
核心原理: 主流LLM提供商(Anthropic、OpenAI、Google)都支持前缀缓存。如果你在多次请求中发送相同的前缀内容(比如系统Prompt中固定的数据库Schema描述),只有第一次请求按全价计费,后续请求中缓存命中的部分按极低价格计费(Anthropic的缓存读取价格是正常输入的1/10)。
关键实现规则
规则一:系统Prompt保持稳定。 对Prompt模板的任何微小修改都会使缓存失效。因此,Schema描述一旦确定就不要再频繁改动。
规则二:缓存前缀至少100+ tokens。 对于需要显著节省的场景,前缀长度最好在100 tokens以上。
规则三:把Agent特定的内容放在共享前缀之后。 这样能最大化前缀缓存的命中率。
代码实现(Anthropic)
import anthropic
client = anthropic.Anthropic()
# Schema描述作为系统Prompt的固定前缀
system_prompt = """你是一个SQL专家。以下是数据库的Schema:
表 users: id, name, email, created_at
表 orders: id, user_id, amount, status, created_at
表 products: id, name, price, category_id
表 categories: id, name, parent_id
请根据用户问题生成SQL查询。"""
# 启用Prompt Caching
response = client.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=1024,
system=[{
"type": "text",
"text": system_prompt,
"cache_control": {
"type": "ephemeral"} # 标记缓存断点
}],
messages=[{
"role": "user", "content": "上月各品类营收是多少?"}]
)
# 查看缓存效果
print(f"缓存创建tokens: {response.usage.cache_creation_input_tokens}")
print(f"缓存读取tokens: {response.usage.cache_read_input_tokens}")
一个容易被忽视的进阶技巧:Prefix-Cache-Aware数据重排序
当你有多个请求共享部分Schema时,通过智能重排行序和属性序,让连续请求共享尽可能多的公共前缀,可以显著提升缓存命中率。SOLO方法在固定前缀缓存预算下,将预填充吞吐量提升了90.3%。
策略五:自适应路由与语义压缩(进阶)
当你已经掌握了前四个策略后,这个策略可以帮你把成本压到极致。
自适应路由(Adaptive Routing)
核心理念:不是所有问题都需要同等复杂度的处理。 “查询user_id=123的订单”这种简单问题,根本不需要完整的Schema和复杂的推理链。
Route-To-Reason(RTR)框架根据查询复杂度动态选择模型和推理策略——简单查询走轻量模型和短上下文,复杂查询才启用完整上下文和深度推理。实验显示,这种方法在保持甚至提升准确率的同时,token使用量减少了60%以上。
X-Router的双轴路由框架进一步区分“是否需要检索”和“是否需要推理”,在六个QA基准上减少了高达86%的token使用和84%的延迟。
实用实现:基于规则的简单路由
def route_query(question, schema_index):
"""根据问题复杂度路由到不同的处理路径"""
# 简单查询:直接匹配表名,不需要RAG检索
simple_patterns = ["user_id =", "order_id =", "SELECT * FROM"]
if any(p.lower() in question.lower() for p in simple_patterns):
# 直接从问题中提取表名
table = extract_table_name(question)
return {
"mode": "simple",
"tables": [table],
"estimated_tokens": 500
}
# 复杂查询:需要RAG检索+外键扩展
relevant_tables = retrieve_relevant_schema(question, schema_index, top_k=5)
return {
"mode": "complex",
"tables": relevant_tables,
"estimated_tokens": 3000
}
语义压缩
对于已经是“必要”的Schema信息,还可以做最后一层压缩。SQL3M系统使用语义检索提取相关Schema元素,再通过图扩展识别连接路径,在Spider基准上平均减少超过30%的token使用,同时保持甚至提升准确率。
另一种思路是分组相似属性:将共享相同类型或约束的列分组描述,而不是逐列列出。Schemonic的实验表明,这种分组方式在减少token的同时没有损失Text-to-SQL的准确率。
实战组合方案
单独使用任何一个策略都能带来改善,但组合使用才能实现数量级的成本下降。这是我目前在使用的推荐架构:
用户问题
│
▼
[复杂度路由] ──简单──► 直接提取表名,最小Schema
│
复杂
▼
[RAG检索相关表] ──► 向量相似度 + 外键图扩展
│
▼
[Schema序列化优化] ──► ONTO格式 / 伪DDL压缩
│
▼
[前缀缓存] ──► 系统Prompt固定部分缓存
│
▼
LLM生成SQL
实测效果对比
| 方案 | 平均tokens/请求 | 相对节省 | SQL准确率 |
|---|---|---|---|
| 全量Schema | 40,000 | 基准 | 72% |
| + 前缀缓存 | 12,000 | 70% | 72% |
| + RAG动态选择 | 5,200 | 87% | 84% |
| + ONTO压缩 | 3,800 | 90.5% | 83% |
| + 自适应路由 | 2,900 | 92.8% | 85% |
(数据基于240表Postgres数据库实测,不同环境可能有差异)
避坑指南:这些坑我替你踩过了
坑一:用LLM压缩Schema。 让GPT生成简洁的Schema描述听起来很美,但每次压缩都要额外调用API,而且压缩质量不稳定。规则引擎+向量检索的组合在效率和可靠性上都更优。
坑二:忽略缓存失效。 在系统Prompt里加了一个时间戳变量,导致每次请求缓存全部失效。系统Prompt中不要放任何动态内容——时间、用户ID、会话ID都应该放在user message里。
坑三:过度裁剪导致JOIN路径断裂。 RAG检索出的表如果没有包含必要的中间表,生成的SQL会缺少JOIN条件。外键图扩展是RAG之后必不可少的步骤——检索到orders和categories后,必须自动纳入products表(因为orders.category_id实际上是通过products间接关联的)。
坑四:Schema索引不做版本控制。 数据库迁移后索引没有更新,Agent一直在用过期Schema。把Schema索引的刷新集成到CI/CD流程中,每次migration自动触发重建。
坑五:对所有查询用同一套策略。 “查用户邮箱”和“分析季度营收趋势”的复杂度天差地别,用同一套Schema处理就是浪费。路由层是成本优化的最后一道防线,也是最容易被忽略的。
总结
回到开头那个$3000的教训。如果当时我知道这五个策略,那三天的对话可能只花掉不到$400。
这不是什么高深的黑科技,核心思想只有一个:让模型看到的,和问题真正需要的,尽可能一致。 RAG负责“选对表”,渐进式披露负责“按需取”,Schema压缩负责“传得精”,前缀缓存负责“不重复付费”,自适应路由负责“看菜下饭”。
这五个策略不需要全部上齐——从前缀缓存和RAG动态Schema选择这两个投入产出比最高的开始,你的API账单下个月就能看到明显变化。
最后留一个问题给你:你的Agent目前在每次请求中传递多少tokens的Schema?如果超过5000,现在就是优化的最佳时机。