上下文窗口与 Function Calling:AI 的记忆与双手

中等 🟡AI 学习
4 个标签
预计阅读时间:13 分钟
上下文窗口Function Calling工具调用LLM应用

上下文窗口与 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助手长时间对话后会发现,模型突然"忘记"了几十轮之前提到的关键设定,比如自己反复强调过的姓名、偏好或任务背景。这并不是模型"变笨"了,而是两个因素共同作用的结果:

1.硬性截断:多数对话式应用为了不超过上下文窗口上限,采用滑动窗口策略,只保留最近N轮对话发给模型,更早的消息被直接从请求中移除,模型自然无从得知。
2.注意力衰减(Lost in the Middle):即便某段信息侥幸留在窗口内,研究发现模型对处于上下文"中间位置"的信息关注度往往低于开头和结尾,这意味着窗口没满也可能出现关键信息被"稀释"、被忽略的情况。

理解这一点很重要:上下文窗口大不代表模型会均匀地"记住"里面的每一句话,窗口只是决定了"能不能看到",而不是"看得有多认真"。

上下文窗口的进化史

从早期模型到今天,上下文窗口经历了近三个数量级的扩张:

| 阶段 | 代表模型 | 上下文窗口量级 |

| --- | --- | --- |

| 早期 | 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窗口,也未必意味着应该把它用满,还是要结合实测效果来决定合理的输入长度。

更大的窗口,更贵的代价

上下文窗口并非越大越好,它直接牵动两个现实约束:

成本:几乎所有大模型API都按输入token和输出token分别计费,上下文窗口用得越满,单次请求的输入token数就越多,账单也就越高。同一个问题,用1K token的精简上下文回答,可能比用100K token的完整历史回答便宜几十甚至上百倍。
延迟:Transformer架构的自注意力机制计算复杂度会随输入长度增长而上升,输入越长,模型"读完"所有内容再开始生成第一个字所需要的时间就越长,直接影响首字延迟(TTFT)和整体响应速度。

因此,工程实践中往往不是"能用多大窗口就用多大",而是"够用就好"——盲目把全部历史都塞进上下文,既费钱又拖慢体验,收益却未必成正比。

如何应对有限的上下文

面对窗口有限、成本敏感的现实,业界常见的应对策略包括:

摘要压缩:定期把较早的对话历史交给模型自己总结成一段简短摘要,用摘要替代原始的大段历史消息,既保留关键信息又大幅节省token。
滑动窗口:只保留最近的N轮对话作为上下文,更早的内容直接丢弃,实现简单但有信息丢失的风险,适合对历史依赖不强的场景。
外部记忆(检索增强):把重要信息(用户偏好、长期事实、文档内容)存入向量数据库等外部存储,每次对话时只检索出与当前问题最相关的少量片段动态注入上下文,而不是把全部历史都塞进窗口。这也是RAG类应用的核心思路之一。

这几种策略并不互斥,实际系统里通常是"滑动窗口保留近期对话 + 摘要浓缩中期信息 + 外部检索补充长期记忆"三者组合使用。

一个直观的成本对照

假设某模型输入token单价是每百万token10元,输出单价是每百万token30元:

一次精简对话(输入1K token,输出300 token):成本约为 1000÷1,000,000×10 + 300÷1,000,000×30 ≈ 0.019元。
一次带完整历史的长对话(输入80K token,输出300 token):成本约为 80000÷1,000,000×10 + 300÷1,000,000×30 ≈ 0.809元。

同样一次提问,仅仅因为携带了更长的历史上下文,成本就可能相差40倍以上。而且要注意,多轮对话中每一轮通常都要把之前所有历史重新发送一遍(模型本身没有"记忆",每次都是从零读取上下文),这意味着对话越长,每一轮的成本和延迟都会随之走高,而不是只有第一轮贵。这也是为什么"控制上下文长度"本身就是一项重要的工程优化,而不只是学术话题。

🛠️ Function Calling:AI 长出双手

从"只会说话"到"能做事"

原生的大语言模型本质上是一个文本生成器:给它一段文字,它续写出下一段文字。它不会主动去查今天的天气、不会真的帮你订机票、也无法读取你数据库里的实时数据——因为它的所有"知识"都封存在训练数据里,训练截止之后发生的一切它都不知道,更没有手脚去操作外部系统。

Function Calling(函数调用/工具调用)正是为了解决这个问题而生的机制。它让模型不再局限于"输出自然语言",而是可以在必要时输出一段结构化的"我想调用哪个工具、传什么参数"的指令,再由外部程序真正执行这个动作,把结果反馈回来供模型继续对话。这一步,是AI从"纸上谈兵"迈向"动手实践"的关键转折点。

工作原理:三步握手

Function Calling的运作可以拆解为一个清晰的三段式流程:

1.定义工具:开发者预先把可用的工具(本质是普通函数)用JSON Schema的形式描述给模型,包括工具名称、用途说明、需要哪些参数、参数类型和是否必填。模型本身并不会真的执行这些函数,它只是"知道有这些工具可以选"。
2.模型决策与生成调用:模型阅读用户的请求后自主判断——这个问题靠自己的知识就能回答,还是必须借助某个外部工具?如果需要借助工具,模型不会直接编造答案,而是输出一个结构化的调用意图,包含要调用的函数名和依据用户意图拼装出的参数。
3.宿主执行并回传结果:开发者编写的宿主程序拦截到这个调用请求,真正运行对应的函数(比如访问天气API、查数据库、执行代码),并把返回的结果重新作为一条消息发回给模型,模型再基于这个真实结果生成最终的自然语言回复。

整个过程中,模型从始至终不会真的"跑代码"或"发请求",它只负责"判断该调用谁、该传什么参数"这一层决策,真正的执行权始终掌握在宿主程序手里——这也是Function Calling相对安全可控的原因之一。

完整示例:一次天气查询的往返

假设我们要做一个能查天气的助手,先给模型声明一个工具(以OpenAI风格的tools参数为例):

jsonCode
{
  "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"]
        }
      }
    }
  ]
}

模型读到用户问题后,判断"实时天气"超出了自己的知识范围,必须调用工具,于是返回的不是一段闲聊文字,而是一个结构化的调用请求:

jsonCode
{
  "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℃",然后把结果作为一条新消息追加回对话历史,再次发给模型:

jsonCode
{
  "role": "tool",
  "tool_call_id": "call_9f2k7a",
  "content": { "city": "上海", "temperature": 26, "condition": "小雨" }
}

模型基于这个真实结果,最终生成给用户的自然语言回复:"上海今天有小雨,气温26℃左右,建议出门带一把伞。"从用户提问到最终回答,模型全程没有编造任何天气数据,所有事实都来自宿主程序真实执行工具后返回的结果,这正是Function Calling相比让模型"凭记忆瞎猜"更可靠的核心原因。

一个工具,无数可能

get_weather只是最简单的示范,实践中工具可以是查数据库、发送邮件、调用搜索引擎、执行代码、操作文件系统、下单支付等任意能被封装成函数的能力。只要清楚地描述好参数结构,模型就能学会在合适的时机选择合适的工具、拼出合理的参数——这一步,是让AI从"只会说话"进化成"能操作外部世界"的关键一步,也是构建能自主完成多步任务的AI Agent的基础能力之一。

一次对话中模型也不局限于只调用一个工具:面对复杂请求时,模型可以在一次响应中同时提出多个并行的工具调用(比如同时查询三座城市的天气),也可以先调用一个工具拿到结果后,再根据结果决定是否需要调用第二个工具,形成"调用—返回—再调用"的多轮链条,直到收集到足够信息才生成最终答案。

落地时容易踩的坑

Function Calling用起来很自然,但工程落地时有几个坑需要格外小心:

参数幻觉:模型有时会"自信地"编造出Schema里根本没有定义的参数,或者把必填参数留空、把字符串错填成数字。宿主程序在真正执行工具前,必须对模型给出的参数做一次严格校验,绝不能盲目信任模型的输出。
不该自动执行的高危操作:像删除数据、转账支付这类不可逆或高风险的操作,即便模型给出了完整合理的调用请求,也建议在宿主程序里加一道"人工确认"关卡,而不是收到调用请求就立刻执行。
工具描述含糊会导致误调用:如果多个工具的description写得相似、边界模糊,模型很容易选错工具或传错参数,因此工具的名称、描述、参数说明本身也需要像提示词一样反复打磨。

上下文窗口决定了AI一次能装下多少信息,Function Calling决定了AI能对世界做出多少改变——理解并用好这两个机制,才能真正把大语言模型从一个聊天玩具,变成一个能落地干活的生产力工具。