上下文窗口与 Function Calling:AI 的记忆与双手
上下文窗口与 Function Calling:AI 的记忆与双手
如果说提示工程解决的是"如何对AI说话",那么上下文窗口和Function Calling解决的就是"AI能记住多少"以及"AI能替我们做什么"。上下文窗口是大语言模型工作记忆的物理边界,Function Calling则是大语言模型伸向真实世界的双手。这两个机制看似基础,却是所有实用AI应用(从长对话助手到能操作数据库的Agent)的地基。
🧠 上下文窗口:AI 的工作记忆
什么是上下文窗口
大语言模型并不是像人一样拥有持久记忆的大脑,它更像一块每次对话都要重新擦写的白板。上下文窗口(Context Window)指的是模型单次推理时能够"看到"的token总量上限,这个上限同时涵盖了系统提示、历史对话、用户当前输入,以及模型即将生成的回复。
这里的token并不等于汉字或单词,而是模型分词器(Tokenizer)切分文本后的最小单元。一段中文文本平均每个汉字大约消耗1.5~2个token,英文单词则大约每4个字符消耗1个token。当对话中的文本总量(历史消息+新输入+待生成内容)超过模型的上下文窗口上限时,模型要么直接报错拒绝处理,要么由应用层自动截断掉最早的部分内容。
可以把它想象成开会时的白板:白板面积是固定的,写满了就必须擦掉最早写的内容才能继续记录新内容——被擦掉的部分,参会者(模型)就再也看不到了。
为什么长对话会"失忆"
很多人第一次和AI助手长时间对话后会发现,模型突然"忘记"了几十轮之前提到的关键设定,比如自己反复强调过的姓名、偏好或任务背景。这并不是模型"变笨"了,而是两个因素共同作用的结果:
理解这一点很重要:上下文窗口大不代表模型会均匀地"记住"里面的每一句话,窗口只是决定了"能不能看到",而不是"看得有多认真"。
上下文窗口的进化史
从早期模型到今天,上下文窗口经历了近三个数量级的扩张:
| 阶段 | 代表模型 | 上下文窗口量级 |
| --- | --- | --- |
| 早期 | GPT-3 | 2K~4K tokens |
| 成长期 | GPT-3.5 / GPT-4 初版 | 4K~32K tokens |
| 扩展期 | Claude 2 / GPT-4 Turbo | 100K~128K tokens |
| 长文本期 | Claude 3 系列 | 200K tokens |
| 超长文本期 | Gemini 1.5/2.0 Pro | 1M~2M tokens |
短短几年时间,主流模型的上下文窗口从几千token跃升到百万token级别,这意味着理论上可以一次性把一整本书、一个中型代码仓库或者数百页文档全部塞进一次请求里,不再依赖分段处理。
不过需要提醒一点:厂商宣传的窗口上限是"理论容量",并不完全等于"有效可用容量"。很多评测发现,当输入接近窗口上限时,模型的回答质量、指令遵循能力会出现不同程度的下降,这也是前面提到的"Lost in the Middle"现象的另一种体现。因此在实际工程中,即便模型支持100万token窗口,也未必意味着应该把它用满,还是要结合实测效果来决定合理的输入长度。
更大的窗口,更贵的代价
上下文窗口并非越大越好,它直接牵动两个现实约束:
因此,工程实践中往往不是"能用多大窗口就用多大",而是"够用就好"——盲目把全部历史都塞进上下文,既费钱又拖慢体验,收益却未必成正比。
如何应对有限的上下文
面对窗口有限、成本敏感的现实,业界常见的应对策略包括:
这几种策略并不互斥,实际系统里通常是"滑动窗口保留近期对话 + 摘要浓缩中期信息 + 外部检索补充长期记忆"三者组合使用。
一个直观的成本对照
假设某模型输入token单价是每百万token10元,输出单价是每百万token30元:
同样一次提问,仅仅因为携带了更长的历史上下文,成本就可能相差40倍以上。而且要注意,多轮对话中每一轮通常都要把之前所有历史重新发送一遍(模型本身没有"记忆",每次都是从零读取上下文),这意味着对话越长,每一轮的成本和延迟都会随之走高,而不是只有第一轮贵。这也是为什么"控制上下文长度"本身就是一项重要的工程优化,而不只是学术话题。
🛠️ Function Calling:AI 长出双手
从"只会说话"到"能做事"
原生的大语言模型本质上是一个文本生成器:给它一段文字,它续写出下一段文字。它不会主动去查今天的天气、不会真的帮你订机票、也无法读取你数据库里的实时数据——因为它的所有"知识"都封存在训练数据里,训练截止之后发生的一切它都不知道,更没有手脚去操作外部系统。
Function Calling(函数调用/工具调用)正是为了解决这个问题而生的机制。它让模型不再局限于"输出自然语言",而是可以在必要时输出一段结构化的"我想调用哪个工具、传什么参数"的指令,再由外部程序真正执行这个动作,把结果反馈回来供模型继续对话。这一步,是AI从"纸上谈兵"迈向"动手实践"的关键转折点。
工作原理:三步握手
Function Calling的运作可以拆解为一个清晰的三段式流程:
整个过程中,模型从始至终不会真的"跑代码"或"发请求",它只负责"判断该调用谁、该传什么参数"这一层决策,真正的执行权始终掌握在宿主程序手里——这也是Function Calling相对安全可控的原因之一。
完整示例:一次天气查询的往返
假设我们要做一个能查天气的助手,先给模型声明一个工具(以OpenAI风格的tools参数为例):
{
"model": "gpt-4o",
"messages": [
{ "role": "user", "content": "上海今天天气怎么样?需要带伞吗?" }
],
"tools": [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "查询指定城市当前的实时天气情况",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "城市名称,例如:上海"
},
"unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "返回的温度单位"
}
},
"required": ["city"]
}
}
}
]
}模型读到用户问题后,判断"实时天气"超出了自己的知识范围,必须调用工具,于是返回的不是一段闲聊文字,而是一个结构化的调用请求:
{
"choices": [
{
"message": {
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_9f2k7a",
"type": "function",
"function": {
"name": "get_weather",
"arguments": {
"city": "上海",
"unit": "celsius"
}
}
}
]
},
"finish_reason": "tool_calls"
}
]
}⚠️ 需要说明:真实的OpenAI API中,arguments字段实际上是一段JSON格式的字符串,需要开发者自己用JSON.parse()反序列化成对象后才能使用;这里为了方便阅读直接展示成对象形式。
宿主程序收到这个调用请求后,真正去调用天气服务查到"上海、小雨、26℃",然后把结果作为一条新消息追加回对话历史,再次发给模型:
{
"role": "tool",
"tool_call_id": "call_9f2k7a",
"content": { "city": "上海", "temperature": 26, "condition": "小雨" }
}模型基于这个真实结果,最终生成给用户的自然语言回复:"上海今天有小雨,气温26℃左右,建议出门带一把伞。"从用户提问到最终回答,模型全程没有编造任何天气数据,所有事实都来自宿主程序真实执行工具后返回的结果,这正是Function Calling相比让模型"凭记忆瞎猜"更可靠的核心原因。
一个工具,无数可能
get_weather只是最简单的示范,实践中工具可以是查数据库、发送邮件、调用搜索引擎、执行代码、操作文件系统、下单支付等任意能被封装成函数的能力。只要清楚地描述好参数结构,模型就能学会在合适的时机选择合适的工具、拼出合理的参数——这一步,是让AI从"只会说话"进化成"能操作外部世界"的关键一步,也是构建能自主完成多步任务的AI Agent的基础能力之一。
一次对话中模型也不局限于只调用一个工具:面对复杂请求时,模型可以在一次响应中同时提出多个并行的工具调用(比如同时查询三座城市的天气),也可以先调用一个工具拿到结果后,再根据结果决定是否需要调用第二个工具,形成"调用—返回—再调用"的多轮链条,直到收集到足够信息才生成最终答案。
落地时容易踩的坑
Function Calling用起来很自然,但工程落地时有几个坑需要格外小心:
🧬 KV Cache:长上下文背后的显存账本
要真正理解"为什么上下文越长越贵、越慢",必须先搞懂KV Cache(键值缓存)。它是所有主流大模型推理加速的核心机制,也是长上下文显存占用的主要来源。
KV Cache 到底缓存了什么
Transformer的自注意力机制中,每个token都会被投影成三个向量:Query(查询)、Key(键)、Value(值)。生成第N个token时,模型需要拿当前token的Query去和前面所有token的Key做点积,再对所有Value加权求和。
关键观察是:前面那些token的Key和Value,一旦算出来就不会再变。如果每生成一个新token都把整段历史的K、V重新算一遍,计算量会随序列长度平方级膨胀。于是推理引擎把已经算过的Key、Value缓存在显存里,这就是KV Cache。有了它,生成第N+1个token时只需要算新token的K、V并追加进缓存,历史部分直接复用。
这也解释了一个直觉:预填充(Prefill)阶段要一次性处理整个输入prompt、把所有token的K、V算出来填进缓存,计算量大、耗时长,直接决定首字延迟(TTFT);而解码(Decode)阶段每步只算一个新token,快得多,但每一步都要读写整个KV Cache,因此显存带宽成了瓶颈。
显存占用公式与估算
单个token在KV Cache里占用的显存可以用下面的公式估算:
每token显存 = 2 × 层数 × KV注意力头数 × 每头维度 × 精度字节数
其中:
- 系数 2 代表 Key 和 Value 各存一份
- 精度字节数:FP16/BF16 为 2,FP8 为 1,INT8 为 1以一个类 Llama-2-13B 规模的模型为例(40层、40个KV头、每头128维、FP16):
每token显存 = 2 × 40 × 40 × 128 × 2 字节
= 1,638,400 字节
≈ 1.56 MB / token也就是说,一段32K token的上下文,光是KV Cache就要吃掉约 32000 × 1.56MB ≈ 50GB 显存——已经超过单张A100 40GB的容量。这就是为什么长上下文推理往往需要多卡或显存优化技术。
下面这张表给出几个典型规模模型在不同上下文长度下的KV Cache占用估算(FP16、未做任何压缩):
| 模型规模 | 每token占用 | 8K上下文 | 32K上下文 | 128K上下文 |
| --- | --- | --- | --- | --- |
| 7B(32层/32头/128维) | 约0.5MB | 约4GB | 约16GB | 约64GB |
| 13B(40层/40头/128维) | 约1.56MB | 约12.5GB | 约50GB | 约200GB |
| 70B(80层/64头/128维,GQA 8组) | 约0.31MB | 约2.5GB | 约10GB | 约40GB |
注意最后一行:70B模型参数量远大于13B,KV Cache反而更小,原因是它用了分组查询注意力(GQA)——多个Query头共享同一组KV头,把KV头数从64压到8,KV Cache直接缩小到八分之一。这正是现代大模型能撑起长上下文的关键工程手段之一。
降低 KV Cache 占用的主流技术
| 技术 | 核心思路 | 显存收益 | 代价/风险 |
| --- | --- | --- | --- |
| MQA(多查询注意力) | 所有Query头共享1组KV | 极大 | 质量下降较明显 |
| GQA(分组查询注意力) | 每组Query头共享1组KV | 大 | 质量损失可控,现主流 |
| KV Cache量化 | 把K、V从FP16降到FP8/INT8 | 一半到四分之一 | 极端场景略掉精度 |
| PagedAttention | 像操作系统分页管理显存碎片 | 提升利用率2到4倍 | 需专用推理引擎 |
| 滑动窗口注意力 | 只缓存最近窗口内的KV | 与窗口成正比 | 丢失远距离信息 |
vLLM的PagedAttention是工程上影响最大的一项:传统实现给每个请求预留连续显存,浪费严重;PagedAttention把KV Cache切成固定大小的块,按需分配、非连续存储,把显存浪费从60%~80%降到4%以下,从而在同样硬件上把吞吐量提升数倍。
📐 位置编码与上下文窗口扩展
模型天生并不"知道"token之间的先后顺序,位置信息是靠位置编码(Positional Encoding)注入的。上下文窗口能不能被"撑大",很大程度上取决于位置编码的设计。
RoPE:旋转位置编码
当前主流开源模型(Llama、Qwen、Mistral等)几乎都用RoPE(Rotary Position Embedding,旋转位置编码)。它的巧妙之处在于:不是把位置信息直接加到词向量上,而是根据token的位置,对Query和Key向量做一个角度随位置递增的旋转。两个token做注意力点积时,结果只依赖它们的相对距离,而非绝对位置。
RoPE中每个维度对应一个旋转频率,低维旋转快(捕捉近距离关系),高维旋转慢(捕捉远距离关系)。这个设计让RoPE具备一定的外推潜力,但直接外推到远超训练长度的位置时,模型会遇到从未见过的旋转角度,性能急剧崩溃。为解决这个问题,衍生出了几种窗口扩展技术。
位置插值(PI)、NTK-aware 与 YaRN
| 方法 | 核心思想 | 是否需要微调 | 特点 |
| --- | --- | --- | --- |
| 位置插值 PI | 把超长位置"压缩"回训练范围内(线性缩放位置索引) | 通常需少量微调 | 简单有效,高频信息略受损 |
| NTK-aware | 不均匀缩放:高频少缩放、低频多缩放 | 可免微调直接用 | 缓解高频信息丢失 |
| Dynamic NTK | 缩放系数随实际输入长度动态调整 | 免微调 | 短输入不受影响 |
| YaRN | 结合NTK的分频缩放 + 注意力温度校正 | 少量微调即可 | 扩展倍数大、质量高,被广泛采用 |
用一个类比理解位置插值:假设模型只在"0到2048号座位"的教室里训练过,现在要塞进4096个人。PI的做法是把4096个人的编号线性映射回0~2048的范围(每个人占半个座位号),模型见到的位置索引仍在熟悉范围内。缺点是相邻token的位置差被压缩,近距离的精细分辨能力略有损失。
NTK-aware插值更聪明:它认识到RoPE不同维度承担不同职责,于是对高频维度(管近距离)少动、对低频维度(管远距离)多缩放,从而在扩展窗口的同时尽量保住近距离分辨力。YaRN在此基础上进一步引入注意力分数的温度校正,是目前把模型从4K扩到128K乃至更长时综合效果最好的方案之一,很多长上下文开源模型都基于它实现。
一个常见的实践配置片段(以transformers为例):
from transformers import AutoModelForCausalLM
# 通过 rope_scaling 把原生窗口按 YaRN 方式扩展
model = AutoModelForCausalLM.from_pretrained(
"your-org/your-7b-model",
rope_scaling={
"type": "yarn",
"factor": 4.0, # 扩展倍数:4K -> 16K
"original_max_position_embeddings": 4096,
},
torch_dtype="bfloat16",
device_map="auto",
)需要强调:把窗口"技术上撑大"和"有效可用"是两回事。扩展后仍要用长文本检索、多跳问答等任务实测,确认模型在长距离上确实没有明显退化,才能放心投产。
💰 Prompt 缓存 / Context Caching:省钱的关键杠杆
前面算过一笔账:多轮对话每一轮都要把全部历史重新发一遍,token成本随对话变长而水涨船高。厂商为此推出了Prompt缓存(Prompt Caching / Context Caching)——把重复出现的长前缀(系统提示、文档、few-shot示例)在服务端缓存起来,后续请求命中缓存的部分按大幅折扣计费,同时还能显著降低首字延迟。
各厂商方案与折扣对照
| 厂商/机制 | 缓存写入成本 | 缓存命中读取成本 | 默认存活时间 | 触发方式 |
| --- | --- | --- | --- | --- |
| Anthropic 提示缓存 | 约为基础输入的1.25倍 | 约为基础输入的0.1倍(省90%) | 5分钟(可选更长) | 显式打 cache_control 断点 |
| OpenAI 提示缓存 | 与普通输入同价 | 约为基础输入的0.5倍(省50%) | 数分钟自动 | 自动,前缀≥1024 token时生效 |
| Google 上下文缓存 | 按缓存token量另计存储费 | 命中部分大幅折扣 | 可自定义TTL | 显式创建缓存对象 |
Anthropic 的显式缓存示例(省钱幅度最大,适合超长稳定前缀):
import anthropic
client = anthropic.Anthropic()
response = client.messages.create(
model="claude-sonnet-4-5",
max_tokens=1024,
system=[
{
"type": "text",
"text": "你是一名资深法律顾问,以下是需要遵循的完整合同审查规范……",
},
{
"type": "text",
"text": LONG_CONTRACT_TEMPLATE, # 几万 token 的长文档
"cache_control": {"type": "ephemeral"}, # 在此处打缓存断点
},
],
messages=[
{"role": "user", "content": "请审查这份新合同的第3条是否合规。"}
],
)
# 首次请求写入缓存(略贵);5分钟内的后续请求命中缓存,
# 前缀部分按约 1/10 计费,TTFT 也明显下降。
usage = response.usage
print("缓存写入 token:", usage.cache_creation_input_tokens)
print("缓存命中 token:", usage.cache_read_input_tokens)
print("常规输入 token:", usage.input_tokens)一个真实的成本节省测算
设想一个企业知识库问答场景:系统提示 + 产品手册长前缀共 50,000 token 固定不变,用户问题平均 200 token,每天 10,000 次请求,模型输入单价按每百万 token 3 美元计。
| 方案 | 每次输入 token | 每次输入成本 | 每日输入成本 |
| --- | --- | --- | --- |
| 不用缓存 | 50,200 | 约0.1506美元 | 约1506美元 |
| 用缓存(命中90%折扣) | 前缀5000等效+200 | 约0.0156美元 | 约156美元 |
仅输入侧,每天就从约1506美元降到约156美元,节省近90%。这也是为什么"把稳定内容放在prompt开头、把易变内容放在结尾"成了长上下文应用的黄金排版原则——只有前缀稳定不变,缓存才能持续命中。
🔎 长上下文时代,为什么还需要检索
有人会问:既然窗口都到百万token了,把所有文档一次性塞进去不就行了,还要RAG做什么?答案是:能塞不代表划算、也不代表准确。
所以主流实践是"检索 + 长上下文"配合:用检索把候选范围缩小,再用较大的窗口容纳检索结果和推理过程。
分块(Chunking)策略对比
检索质量的上限,很大程度上在切分文档这一步就被决定了。
| 策略 | 做法 | 优点 | 缺点 |
| --- | --- | --- | --- |
| 固定长度切分 | 按固定token数硬切 | 实现最简单 | 常从句子中间切断,破坏语义 |
| 重叠滑窗切分 | 固定长度 + 相邻块重叠一部分 | 缓解边界信息割裂 | 有冗余、略增存储 |
| 递归结构切分 | 按段落→句子→词逐级回退切分 | 尽量保持语义完整 | 需调分隔符优先级 |
| 语义切分 | 按句向量相似度在语义断点处切 | 块内主题最聚焦 | 计算开销大 |
| 基于文档结构切分 | 按Markdown标题/章节切 | 保留层级上下文 | 依赖文档规整度 |
一段带重叠的递归切分示例(LangChain 风格):
from langchain_text_splitters import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=800, # 每块约800字符
chunk_overlap=120, # 相邻块重叠120字符,避免边界语义割裂
separators=["\n\n", "\n", "。", "!", "?", " ", ""], # 逐级回退
length_function=len,
)
docs = splitter.split_text(long_document)
print(f"共切分出 {len(docs)} 块")
for i, d in enumerate(docs[:3]):
print(f"--- 第{i}块({len(d)}字)---")
print(d)检索 + Rerank 提升精度
向量检索(召回)追求快和全,但排在前面的不一定最相关。工程上常加一层重排序(Rerank):先用向量检索粗召回20~50条,再用一个更精细的交叉编码器(Cross-Encoder)对"查询-文档"逐对打分,取Top-K送入上下文。
from sentence_transformers import CrossEncoder
# 1) 向量检索粗召回(伪代码,candidates 来自向量库 top-30)
candidates = vector_store.similarity_search(query, k=30)
# 2) 交叉编码器精排
reranker = CrossEncoder("BAAI/bge-reranker-large")
pairs = [(query, doc.page_content) for doc in candidates]
scores = reranker.predict(pairs)
# 3) 按精排分数取前 5 条注入上下文
ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
top_docs = [doc for doc, score in ranked[:5]]
context = "\n\n".join(d.page_content for d in top_docs)
prompt = f"根据以下资料回答问题:\n{context}\n\n问题:{query}"实践数据表明,在同样召回规模下,加一层rerank通常能把答案的准确率(尤其是Top-1命中率)提升10到25个百分点,代价只是每次多几十毫秒的排序开销,性价比很高。
🧠 对话记忆管理:一套可落地的混合实现
理论讲完,来看一个把"滑动窗口 + 摘要压缩 + 向量检索"三种策略融合的完整对话记忆管理类。它体现了生产级对话助手常见的记忆架构。
from dataclasses import dataclass, field
from typing import List, Dict
import tiktoken
@dataclass
class Message:
role: str
content: str
class HybridMemory:
"""混合记忆:近期原文滑窗 + 中期摘要 + 长期向量检索。"""
def __init__(
self,
model: str = "gpt-4o",
max_window_tokens: int = 4000, # 滑窗预算
summary_trigger: int = 3000, # 超过该阈值触发摘要
recent_keep: int = 6, # 摘要后保留的最近消息条数
):
self.enc = tiktoken.encoding_for_model(model)
self.max_window_tokens = max_window_tokens
self.summary_trigger = summary_trigger
self.recent_keep = recent_keep
self.messages: List[Message] = [] # 近期原文
self.summary: str = "" # 中期摘要
self.vector_store = SimpleVectorStore() # 长期记忆(见下)
def _count(self, text: str) -> int:
return len(self.enc.encode(text))
def _window_tokens(self) -> int:
return sum(self._count(m.content) for m in self.messages)
def add(self, role: str, content: str):
self.messages.append(Message(role, content))
# 把每条消息也写入长期向量记忆,便于日后语义检索
self.vector_store.add(content, metadata={"role": role})
if self._window_tokens() > self.summary_trigger:
self._compress()
def _compress(self):
"""把较早的消息摘要化,只保留最近 recent_keep 条原文。"""
to_summarize = self.messages[:-self.recent_keep]
if not to_summarize:
return
joined = "\n".join(f"{m.role}: {m.content}" for m in to_summarize)
new_summary = llm_summarize(
f"已有摘要:\n{self.summary}\n\n"
f"请把下列新增对话合并进摘要,保留关键事实、决定、用户偏好,"
f"控制在300字以内:\n{joined}"
)
self.summary = new_summary
self.messages = self.messages[-self.recent_keep:]
def build_context(self, user_query: str) -> List[Dict]:
"""组装最终发给模型的消息列表。"""
ctx: List[Dict] = []
# 1) 长期记忆:按当前问题语义检索最相关的历史片段
recalled = self.vector_store.search(user_query, k=3)
if recalled:
ctx.append({
"role": "system",
"content": "以下是可能相关的历史记忆:\n" + "\n".join(recalled),
})
# 2) 中期记忆:摘要
if self.summary:
ctx.append({"role": "system", "content": f"对话摘要:{self.summary}"})
# 3) 近期记忆:原文滑窗
for m in self.messages:
ctx.append({"role": m.role, "content": m.content})
# 4) 当前问题
ctx.append({"role": "user", "content": user_query})
return ctx配套的一个极简向量存储(示意,生产环境请用专业向量库):
import numpy as np
class SimpleVectorStore:
def __init__(self):
self.texts: List[str] = []
self.vectors: List[np.ndarray] = []
def add(self, text: str, metadata: dict = None):
vec = embed(text) # 调用嵌入模型,返回归一化向量
self.texts.append(text)
self.vectors.append(vec)
def search(self, query: str, k: int = 3) -> List[str]:
if not self.vectors:
return []
q = embed(query)
sims = [float(np.dot(q, v)) for v in self.vectors]
top_idx = np.argsort(sims)[::-1][:k]
return [self.texts[i] for i in top_idx if sims[i] > 0.7] # 相似度阈值过滤这套结构的价值在于:无论对话进行到第几百轮,实际发给模型的上下文都被控制在一个稳定预算内(滑窗)+ 一段浓缩摘要(不丢主线)+ 少量按需召回的相关历史(不丢细节),三者互补,既不爆窗口也不失忆。
🔧 Function Calling 进阶:并行、多步与 ReAct 循环
前面讲了单次工具调用的三步握手,真实Agent的调用往往是多步、循环的。下面用完整代码把常见模式串起来。
完整的工具调用循环(Python)
import json
from openai import OpenAI
client = OpenAI()
# 1) 定义工具的 JSON Schema
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "查询指定城市的实时天气",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "城市名"},
},
"required": ["city"],
},
},
},
{
"type": "function",
"function": {
"name": "search_flights",
"description": "查询两地之间某天的航班",
"parameters": {
"type": "object",
"properties": {
"origin": {"type": "string"},
"destination": {"type": "string"},
"date": {"type": "string", "description": "YYYY-MM-DD"},
},
"required": ["origin", "destination", "date"],
},
},
},
]
# 2) 工具的真实实现(宿主侧)
def get_weather(city: str) -> dict:
return {"city": city, "temp": 26, "condition": "小雨"}
def search_flights(origin: str, destination: str, date: str) -> dict:
return {"flights": [{"no": "MU5101", "price": 880, "depart": "08:30"}]}
TOOL_IMPL = {"get_weather": get_weather, "search_flights": search_flights}
def run_agent(user_query: str, max_steps: int = 6) -> str:
messages = [
{"role": "system", "content": "你是一个出行助手,善用工具获取实时信息。"},
{"role": "user", "content": user_query},
]
for step in range(max_steps):
resp = client.chat.completions.create(
model="gpt-4o",
messages=messages,
tools=tools,
tool_choice="auto", # 让模型自主决定是否调用工具
parallel_tool_calls=True, # 允许一次返回多个并行调用
)
msg = resp.choices[0].message
messages.append(msg)
# 模型没有再要工具,说明它给出了最终答案
if not msg.tool_calls:
return msg.content
# 逐个执行模型请求的工具(可能是多个并行调用)
for call in msg.tool_calls:
name = call.function.name
args = json.loads(call.function.arguments) # 注意:arguments 是字符串
try:
result = TOOL_IMPL[name](**args)
except Exception as e:
result = {"error": str(e)}
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": json.dumps(result, ensure_ascii=False),
})
return "已达到最大步数上限,仍未得到最终答案。"
print(run_agent("我明天从上海去北京,先告诉我两地天气,再帮我看看航班。"))这段代码里有几个进阶要点:`tool_choice="auto"` 把是否调用工具的决定权交给模型;`parallel_tool_calls=True` 允许模型在一次响应里同时请求"查上海天气""查北京天气""查航班"三个调用,宿主并发执行后一起回传,比串行往返省下大量延迟;外层的 `for step` 循环则实现了"调用→拿结果→再决策"的多步链条,并用 `max_steps` 兜底防止无限循环。
ReAct:把推理和行动交织起来
ReAct(Reasoning + Acting)是Agent领域的经典范式:让模型在每一步先"想"(Thought),再"做"(Action),观察结果(Observation),如此循环直到得出答案。现代Function Calling其实就是ReAct的结构化实现,但显式写出Thought有助于提升复杂任务的成功率和可解释性。
REACT_SYSTEM = """你要用如下循环解决问题:
Thought: 分析现状,思考下一步该做什么
Action: 选择一个工具并给出参数
Observation: (由系统填入工具返回结果)
... 循环若干轮 ...
Thought: 我已经掌握足够信息
Final Answer: 给出最终答案
可用工具:
- search(query): 联网搜索
- calculator(expr): 计算数学表达式
"""
def react_loop(question: str, max_iters: int = 8) -> str:
scratchpad = ""
for _ in range(max_iters):
prompt = f"{REACT_SYSTEM}\n\n问题:{question}\n{scratchpad}Thought:"
text = llm_complete(prompt, stop=["Observation:"])
scratchpad += "Thought:" + text
if "Final Answer:" in text:
return text.split("Final Answer:")[-1].strip()
# 解析 Action 行,执行工具
action, arg = parse_action(text) # 从文本中抽取工具名与参数
observation = TOOLS[action](arg)
scratchpad += f"\nObservation: {observation}\n"
return "超出最大迭代次数。"ReAct的价值在于"显式推理":模型把每一步的思考写出来,既能自我纠错(发现上一步观察结果不对时调整策略),也让开发者能在日志里清楚看到Agent的决策链,便于调试。
📦 结构化输出:JSON mode 与 Schema 约束
Function Calling本质上是"让模型输出结构化数据"的一种特例。很多场景我们并不需要真的执行函数,只是想让模型稳定地吐出规整的JSON——这就是结构化输出(Structured Output)。
三种约束强度
| 方式 | 保证程度 | 说明 |
| --- | --- | --- |
| 提示词里要求"返回JSON" | 弱 | 模型可能夹带解释文字、格式偶尔出错 |
| JSON mode | 中 | 保证是合法JSON,但不保证字段结构 |
| JSON Schema / Structured Outputs | 强 | 保证严格符合给定Schema,字段、类型、枚举都受约束 |
用 Pydantic 定义并校验
Python生态里,最优雅的做法是用Pydantic定义数据模型,既能生成JSON Schema喂给模型,又能在拿到结果后做严格校验:
from pydantic import BaseModel, Field, field_validator
from typing import Literal
from openai import OpenAI
client = OpenAI()
class Invoice(BaseModel):
vendor: str = Field(description="开票方公司名称")
amount: float = Field(description="金额,单位元", gt=0)
currency: Literal["CNY", "USD", "EUR"] = "CNY"
invoice_date: str = Field(description="开票日期 YYYY-MM-DD")
items: list[str] = Field(description="商品或服务明细")
@field_validator("invoice_date")
@classmethod
def check_date(cls, v: str) -> str:
import re
if not re.match(r"^\d{4}-\d{2}-\d{2}$", v):
raise ValueError("日期格式必须为 YYYY-MM-DD")
return v
# 用 SDK 的结构化输出能力,直接把 Pydantic 模型作为响应格式
completion = client.beta.chat.completions.parse(
model="gpt-4o",
messages=[
{"role": "system", "content": "你是发票信息抽取助手。"},
{"role": "user", "content": "从这段文字抽取发票信息:上海某某科技有限公司,"
"2026年8月3日开具,金额12800元,内容为云服务费。"},
],
response_format=Invoice, # 直接传 Pydantic 模型
)
invoice: Invoice = completion.choices[0].message.parsed
print(invoice.vendor, invoice.amount, invoice.invoice_date)
# 拿到的 invoice 已经是通过 Pydantic 校验的强类型对象这样做的好处是把"模型输出"和"业务类型系统"打通:模型返回的结构一旦不符合Schema(少字段、类型错、枚举越界),要么在解析阶段被拒,要么被Pydantic校验拦下,绝不会有脏数据静默流入下游。
🔌 MCP:给工具生态定一个标准接口
工具越用越多,一个现实问题浮现:每接入一个新工具/数据源,都要为每个模型、每个应用重写一遍对接代码,组合爆炸。MCP(Model Context Protocol,模型上下文协议)就是为解决这个"M×N对接"问题而生的开放标准。
它的定位类似"AI应用的USB-C接口":工具提供方按MCP标准实现一个MCP Server(暴露工具、资源、提示模板),AI应用作为MCP Client统一接入。这样一个MCP Server写一次,任何支持MCP的客户端都能用;一个客户端也能即插即用地接入任意MCP Server。
一个最小的MCP Server(Python)暴露工具的样子:
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("demo-tools")
@mcp.tool()
def query_order(order_id: str) -> dict:
"""根据订单号查询订单状态。
Args:
order_id: 订单编号,如 ORD-20260803-001
"""
# 真实实现里这里会查数据库
return {"order_id": order_id, "status": "已发货", "eta": "2026-08-05"}
@mcp.resource("config://company-policy")
def company_policy() -> str:
"""把公司政策作为只读资源暴露给模型。"""
return "退货政策:签收7日内无理由退货……"
if __name__ == "__main__":
mcp.run() # 以 stdio 方式启动,等待 MCP 客户端连接MCP的核心概念有三类:Tools(模型可调用的动作,等价于Function Calling的工具)、Resources(模型可读取的只读上下文,如文件、配置)、Prompts(可复用的提示模板)。它把散落各处的工具接入方式收敛成一套协议,是当下Agent工具生态走向标准化的重要一步。
🧭 工具设计与"工具过多"的路由问题
好工具的设计原则
工具太多怎么办:工具检索/路由
当工具数量从几个涨到几十上百个,把所有工具的Schema一次性塞进上下文既昂贵又会降低选择准确率(选项太多模型反而选不准)。解决思路是工具检索(Tool RAG):先对工具的名称+描述做向量化建库,运行时根据用户当前意图检索出最相关的少数几个工具,只把这几个的Schema注入本轮请求。
# 把所有工具的描述建成向量索引(离线一次)
tool_index = SimpleVectorStore()
for t in ALL_TOOLS: # 可能有上百个
desc = f"{t['function']['name']}: {t['function']['description']}"
tool_index.add(desc, metadata={"tool": t})
def select_tools(user_query: str, top_k: int = 5) -> list:
"""运行时按意图动态挑选最相关的少数工具。"""
hits = tool_index.search(user_query, k=top_k)
return [h.metadata["tool"] for h in hits]
# 每轮只把挑出来的 5 个工具的 Schema 发给模型
selected = select_tools("帮我把这个月的报销单导出成表格")
resp = client.chat.completions.create(model="gpt-4o", messages=msgs, tools=selected)另一种常见做法是分层路由:先让一个"路由模型"判断请求属于哪个工具域(如"财务类/日程类/搜索类"),再只加载该域下的工具集,两级筛选进一步压缩候选。
🛡️ 生产级可靠性:错误处理、重试、超时、幂等、人工确认
Demo里工具总是成功返回,生产环境里工具会超时、会报错、会返回脏数据。一个健壮的工具执行层通常要处理下面这些问题。
import time
import hashlib
from functools import wraps
# 已执行过的高危操作记录(幂等用),生产环境应落库/Redis
_executed_ops = {}
def with_retry(max_attempts=3, base_delay=1.0, timeout=10.0):
"""带指数退避重试 + 超时的工具执行装饰器。"""
def deco(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
last_err = None
for attempt in range(1, max_attempts + 1):
try:
# 简化示意:真实项目用 signal/concurrent.futures 控制超时
return fn(*args, **kwargs)
except (TimeoutError, ConnectionError) as e:
last_err = e
delay = base_delay * (2 ** (attempt - 1)) # 1s, 2s, 4s
time.sleep(delay)
return {"error": f"工具执行失败(已重试{max_attempts}次): {last_err}"}
return wrapper
return deco
def idempotent(fn):
"""对写操作做幂等保护:相同参数只真正执行一次。"""
@wraps(fn)
def wrapper(*args, **kwargs):
key = hashlib.md5(
(fn.__name__ + str(args) + str(kwargs)).encode()
).hexdigest()
if key in _executed_ops:
return _executed_ops[key] # 直接返回上次结果,避免重复扣款/重复下单
result = fn(*args, **kwargs)
_executed_ops[key] = result
return result
return wrapper
HIGH_RISK = {"refund", "delete_account", "transfer_money"}
def execute_tool(name: str, args: dict, tools_impl: dict):
"""统一的工具执行入口:校验 + 高危拦截 + 执行。"""
# 1) 参数校验:不信任模型输出
if name not in tools_impl:
return {"error": f"未知工具: {name}"}
# 2) 高危操作插入人工确认关卡
if name in HIGH_RISK:
if not request_human_approval(name, args):
return {"status": "cancelled", "reason": "用户未批准该操作"}
# 3) 真正执行
return tools_impl[name](**args)
@idempotent
@with_retry(max_attempts=3)
def transfer_money(to: str, amount: float) -> dict:
# ... 真实转账逻辑 ...
return {"status": "ok", "to": to, "amount": amount}把这些机制串起来,一次工具调用的生产级生命周期是:模型给出调用意图 → 宿主对参数做Schema校验(挡住参数幻觉)→ 判断是否高危、必要时走人工确认 → 带超时和重试地执行(应对抖动)→ 对写操作做幂等保护(防重复副作用)→ 结果结构化回传。任何一环缺失,都可能在真实流量下酿成事故(重复扣款、误删数据、无限重试打爆下游)。
📊 真实案例:一个客服 Agent 的成本与成功率
设想一个电商客服Agent,接入了"查订单、查物流、申请退款、查退换货政策"四个工具,用GPT-4o级别模型。上线后一段时间的实测数据大致如下(示意性数字,用于说明量级关系):
| 指标 | 朴素实现 | 优化后 |
| --- | --- | --- |
| 平均每次会话token | 约18,000 | 约6,500 |
| 工具调用成功率 | 82% | 96% |
| 平均响应延迟 | 4.2秒 | 1.8秒 |
| 单次会话成本 | 约0.12美元 | 约0.03美元 |
| 需转人工比例 | 21% | 9% |
从朴素到优化,主要做了这几件事:用Prompt缓存缓存固定的系统提示和政策文档(省token、降延迟);把"每轮重发全部历史"改成"摘要+滑窗"(省token);给工具参数加严格Schema校验和枚举约束(把成功率从82%提到96%,主要是消灭了参数幻觉导致的调用失败);开启并行工具调用(同时查订单和物流,降延迟);给退款这类高危操作加人工确认关卡(既合规又减少误操作)。
可以看到,单次会话成本下降到原来的四分之一,成功率、延迟、人工介入率同步改善。这组数字很能说明问题:上下文管理和工具工程不是"锦上添花"的优化,而是直接决定Agent能否规模化盈利的生死线。
🧪 主流模型上下文与工具能力横向对比
下面这张表汇总了几类代表性模型在上下文窗口和工具能力上的定位(数字为量级示意,具体以各厂商最新文档为准):
| 模型系列 | 上下文窗口量级 | 提示缓存 | 并行工具调用 | 结构化输出 | 典型定位 |
| --- | --- | --- | --- | --- | --- |
| Claude 系列 | 200K级 | 支持(省90%) | 支持 | 支持 | 长文档、复杂Agent |
| GPT-4o / 4.1 | 128K~1M级 | 支持(省50%) | 支持 | 支持(Schema严格) | 通用、生态成熟 |
| Gemini 1.5/2.0 | 1M~2M级 | 支持(显式缓存) | 支持 | 支持 | 超长上下文、多模态 |
| 开源(Qwen/Llama等) | 32K~128K级(可YaRN扩展) | 视部署而定 | 视版本而定 | 可用约束解码 | 私有化、可定制 |
选型时的常见权衡:追求超长一次性输入选Gemini这类百万窗口的;追求Agent工具生态成熟度和结构化输出严格性,GPT系列和Claude系列都很稳;要私有化部署、数据不出内网,则用开源模型配合YaRN扩窗和vLLM推理。没有绝对最优,只有场景匹配。
⚠️ 常见坑清单
| 坑 | 表现 | 应对 |
| --- | --- | --- |
| 窗口够大就全塞 | 成本飙升、被无关内容干扰答错 | 检索+精排,只注入高相关内容 |
| 易变内容放前缀 | Prompt缓存永远命中不了 | 稳定内容放开头,易变内容放结尾 |
| 盲信模型给的参数 | 参数幻觉导致工具报错/脏数据 | 执行前用Schema/Pydantic严格校验 |
| arguments当对象直接用 | 类型报错 | 记住它是JSON字符串,需先反序列化 |
| 高危操作自动执行 | 误删、重复扣款等事故 | 加人工确认关卡 + 幂等保护 |
| 工具描述含糊雷同 | 模型选错工具、传错参数 | 名称/描述像提示词一样打磨 |
| 无步数上限的调用循环 | 陷入无限调用、成本失控 | 设max_steps兜底 |
| 工具太多全量注入 | 选择准确率下降、token浪费 | 工具检索/分层路由动态挑选 |
| 忽视Lost in the Middle | 关键信息夹在中间被忽略 | 重要信息放首尾、精简中段 |
| 长对话每轮重发全历史 | 成本随轮数线性增长 | 摘要+滑窗+向量检索混合记忆 |
✅ 最佳实践清单
🧾 核心概念总结对照表
| 概念 | 一句话本质 | 关键收益 | 主要代价/风险 |
| --- | --- | --- | --- |
| 上下文窗口 | 模型单次能看到的token上限 | 决定能装多少信息 | 越大越贵越慢 |
| KV Cache | 缓存历史token的K、V避免重算 | 大幅加速解码 | 长上下文吃显存 |
| GQA/MQA | 多Query头共享KV头 | KV Cache缩数倍 | 极端压缩略掉质量 |
| RoPE | 用旋转注入相对位置信息 | 支持相对位置、可外推 | 直接外推会崩 |
| YaRN/NTK/PI | 把窗口撑到训练长度之外 | 低成本扩窗 | 需验证有效性 |
| Prompt缓存 | 缓存稳定长前缀按折扣计费 | 省成本、降延迟 | 前缀须稳定 |
| 分块+Rerank | 切好文档再精排相关片段 | 提升检索准确率 | 增少量算力 |
| 混合记忆 | 滑窗+摘要+向量检索 | 长对话不爆窗不失忆 | 实现较复杂 |
| Function Calling | 模型输出结构化调用意图 | 让AI能操作外部世界 | 参数幻觉、需校验 |
| 并行工具调用 | 一次响应发多个调用 | 降延迟 | 需宿主并发执行 |
| ReAct | 想—做—观察循环 | 复杂任务成功率高、可解释 | 多轮更耗token |
| 结构化输出 | 强约束模型吐规整JSON | 打通业务类型系统 | 需定义Schema |
| MCP | 工具接入的标准协议 | 一次实现处处可用 | 生态仍在成熟 |
| 工具路由 | 按意图动态挑工具 | 工具再多也不掉准 | 需额外检索层 |
理解了从"能装多少"(上下文窗口、KV Cache、窗口扩展)到"如何装得聪明"(缓存、检索、混合记忆),再到"如何安全地动手"(Function Calling、结构化输出、MCP、可靠性工程),你就掌握了把大模型从聊天框推向生产系统的整条主线。
上下文窗口决定了AI一次能装下多少信息,Function Calling决定了AI能对世界做出多少改变——理解并用好这两个机制,才能真正把大语言模型从一个聊天玩具,变成一个能落地干活的生产力工具。