我们和模型聊旅行,先说了目的地、预算和人数,又补充了交通要求,最后只问一句:“住哪儿合适?”
要回答这句话,模型需要知道前面讨论过什么。我们只看到聊天框里新发的一条消息,程序发送请求时,却可能已经把系统指令、前面的对话和当前问题放在了一起。
模型在这次生成中能利用的这些信息,就是上下文(context)。它决定了模型回答时有哪些信息可用,也决定了这次调用要处理多少输入。检索资料、工具定义和工具执行结果,也可以成为上下文的一部分。
上下文的构成
一个聊天应用在组装请求时,通常会从下面几部分取出信息:
| 组成 | 作用 | 例子 |
|---|---|---|
| 系统指令 | 约束任务、行为和输出要求 | 旅行助手的职责、回答语言、资料使用规则 |
| 当前消息 | 表达本轮任务 | “住哪儿合适?” |
| 历史消息 | 保留已经提出的条件和讨论过程 | 目的地、预算、人数、之前的建议 |
| 外部资料 | 补充模型回答所需的信息 | 从知识库检索出的住宿资料 |
| 工具定义 | 描述可用工具及参数 | 住宿查询工具的名称、说明和参数 Schema |
| 工具结果 | 提供程序执行后取得的信息 | 查询返回的房型、价格和位置 |
这些内容不一定各占一个接口字段。历史对话通常放在 messages 中,参考资料可以加入消息正文,工具定义则可能通过 tools 传入。服务或聊天模板再按照模型要求,把它们转换成模型接收的输入。这个转换过程可参考 聊天模板文档。
我们说“输入上下文”时,通常指开始生成之前提供的内容。模型生成回答后,已经写出的 Token 也会影响接下来怎样生成,不过它们属于本次新产生的输出。统计输入用量时,需要把两部分分开。
消息结构与角色
聊天接口通常用消息列表组织对话。每条消息通过 role 表明是谁说的,通过 content 保存正文。常见角色有 system、user、assistant 和 tool,分别对应系统指令、用户消息、助手消息和工具结果。
沿用前面的旅行场景,把对话展开:
| 轮次 | 用户 | 助手 |
|---|---|---|
| 1 | 周末去杭州玩两天,两个人,总预算1000元。 | 可以先给住宿留400元,其他600元分给交通、餐饮和门票。 |
| 2 | 不租车,只坐地铁和公交。 | 那就优先选地铁站附近,住宿仍按400元以内找。 |
| 3 | 住哪儿合适? | 待生成 |
第三轮只问了“住哪儿合适”,但目的地、预算和交通要求都在前面的消息里。要保留这些条件,可以把第三轮请求写成下面这样:
{
"messages": [
{
"role": "system",
"content": "你是旅行助手。结合预算和交通条件回答,不编造实时价格。"
},
{
"role": "user",
"content": "周末去杭州玩两天,两个人,总预算1000元。"
},
{
"role": "assistant",
"content": "可以先给住宿留400元,其他600元分给交通、餐饮和门票。"
},
{
"role": "user",
"content": "不租车,只坐地铁和公交。"
},
{
"role": "assistant",
"content": "那就优先选地铁站附近,住宿仍按400元以内找。"
},
{
"role": "user",
"content": "住哪儿合适?"
}
]
}模型收到这份输入后,就有了“两个人、杭州、总预算 1000 元、不租车”等条件,也看到了自己之前提出的住宿预算建议。
这里的角色也有意义。1000 元是用户明确给出的总预算,400 元则是助手建议分给住宿的金额。如果把消息压缩成摘要,也应保留这个区别,避免把助手的建议写成用户已经确认的要求。角色会随聊天格式进入模型输入,并不只是聊天界面上的标签。
上例使用常见的消息格式。不同接口表示系统指令、工具调用的方式可能不同,接入时按对应协议组织,但消息的来源和先后关系仍然要保留。
多轮会话的上下文累积
在自己管理消息列表的应用里,程序每次都要从会话存储中取出历史,与当前消息一起发送。模型服务根据收到的输入生成回答。
如果上面的对话保留全部历史,三次调用分别会带上这些消息:
| 调用 | 消息构成 | 条数 |
|---|---|---|
| 第一次 | 系统指令 + 用户第1条 | 2 |
| 第二次 | 系统指令 + 第一轮问答 + 用户第2条 | 4 |
| 第三次 | 系统指令 + 前两轮问答 + 用户第3条 | 6 |
第一轮生成的回答,到第二轮就成了历史输入;第二轮的回答,到第三轮又被一起带上。于是,用户虽然每次只补一句话,程序发送的内容却越来越多。
聊天记录保存在数据库里,并不表示每次都会全部发送。程序可以只取最近几轮,也可以把早期内容换成摘要,或者按当前问题检索相关历史。模型这次能利用哪些信息,取决于最终传入了什么。几种处理方式见 LLM实现记忆功能的常见模式。
有些接口支持会话 ID 或响应引用,由服务端恢复历史。这种方式省去了客户端重复提交的步骤,但历史仍需要进入模型本次使用的上下文。
反复发送相同前缀时,服务还可能通过缓存复用之前的计算。命中缓存的内容仍属于本次输入,只是计算和计费方式有所不同;是否命中、命中多少,应查看服务返回的用量。DeepSeek 的缓存文档用多轮对话展示了这个过程。
检索与工具扩充
如果回答需要知识库中的资料,程序可以先检索相关片段,再把片段加入上下文。这就是 RAG 在一次模型调用中补充信息的方式。
比如,住宿资料中有一条“示例住宿 A,双人房,每晚 360 元,邻近地铁站”,程序可以把它作为参考资料一起传入。需要标明来源时,再携带 document_id、segment_id 等 metadata,供应用回查知识库原文。这里使用的是演示资料。
选多少片段、保留多少原文、附带哪些来源信息,都由程序决定。参考资料应与应用指令区分开,让模型能够分清哪些是任务要求,哪些是供回答使用的数据。
工具也会补充上下文,不过它分为调用和取得结果两个阶段:
- 选择工具时,输入中包含工具定义,即名称、功能描述和参数 Schema。模型据此生成工具调用请求。
- 取得结果后,程序执行调用,将调用记录和结果追加到上下文,再请求模型生成回答或继续选择工具。
比如,一次住宿查询可以这样进行:
调用1输入:系统指令 + 历史 + 当前问题 + 工具定义
调用1输出:search_stays(city="杭州", max_price=400)
程序执行:查询住宿,取得房型、价格和位置
调用2输入:原上下文 + 工具调用记录 + 工具结果
调用2输出:根据查询结果给出住宿建议第一次调用时,模型还没有查询结果,它根据工具定义提出调用请求。程序执行查询后,把调用记录和结果追加到上下文,模型才能据此回答,或者继续请求其他工具。
工具的内部代码通常不需要传给模型,传入的是工具名称、说明、参数 Schema 和执行结果。工具使用文档介绍了这种交互方式。
所以,聊天界面上的一次提问,可能对应多次模型调用。每次都有各自的输入和输出,记录用量与成本时也需要分别计算。
上下文窗口与输入预算
历史、资料和工具结果不断加入后,输入会越来越长。模型一次能够处理的序列长度有限,这个限制就是上下文窗口,以 Token 计量。
对于输入和输出共用窗口的模型,输入还需要给回答留出空间:
输入 Token + 预留输出 Token ≤ 上下文窗口假设某模型的共享窗口是 32,000 Token,我们打算为回答留出 4,000 Token,那么输入就应控制在 28,000 以内。这只是容量算例,不对应某个具体模型;实际使用时,还要为本地估算与服务计量的差异留出余量。
输入预算要把指令、历史、资料、工具定义、工具结果和消息格式开销一起算进去。输出除了受剩余窗口约束,还可能有独立上限;思考类模型则需要按接口说明确认推理内容怎样计量。
只按对话轮数截取历史,并不能准确控制输入长度。一段很长的工具结果,可能比几十条短消息占用更多空间;重复的检索片段也会白白消耗预算。程序需要先选出与当前任务相关的内容,再按目标 tokenizer 和消息格式核算长度。计数方式见 Token 是什么:为什么字数不能直接换算模型用量。
请求里还有 temperature、输出长度上限、流式开关等参数。它们控制生成和返回方式,不属于对话正文。输出上限用于预留预算,不代表模型一定会用完;打开流式返回,也不会减少已经传入的上下文。
模型回答时忽略了前面提到的预算,可以先检查这项条件是否仍在请求里;输入用量突然增加,则可以检查历史、资料和工具结果是否重复或过长。把本次请求实际带上的内容展开,才容易看清信息在哪里丢失、用量又花在了哪里。