先说一个自己踩过的坑。一开始我只做单轮问答,把用户问题丢给大模型 API,拿回结果就完事,每月的 Token 账单小得几乎可以忽略。后来产品要支持连续对话,我天真地以为无非就是"把聊天记录存下来,下次一起发过去"。结果上线第二个月,账单数字直接翻了几十倍——我才发现,同一段历史对话,每一轮请求都会原封不动地重新发给模型,Token 消耗是逐轮累加的,不是我想象中的"增量保存"。
这个事让我彻底意识到:从单轮到多轮,根本不是"加个数组存聊天记录"那么简单,它背后是上下文窗口的取舍、消息重复计费的逻辑、缓存命中的利用,以及一整套可落地的上下文治理手段。这篇文章就围绕 Ark 类长上下文模型与 OpenAI 风格的 API,聊聊我实测下来的一套上下文管理与 Token 成本分析思路。不管你是刚开始接 API 的新手,还是已经在做客服机器人、Agent 编排的老手,这篇文章应该都能给你一些可以直接抄作业的参考。
1. 单轮调用的真相:为什么它省 Token,也注定走不远
1.1 单轮接口背后的请求-响应模型
几乎所有 OpenAI 风格的大模型 API,底层调用都长一个样:你往messages数组里塞角色和内容,模型返回一条补全结果。最常见的三个角色是 system、user、assistant,分别对应系统设定、用户输入、助手回复。单轮问答的本质就是只发一次请求,请求里没有历史包袱。
import os from openai import OpenAI client = OpenAI( api_key=os.environ.get("API_KEY"), base_url="https://your-gateway.example/v1", ) resp = client.chat.completions.create( model="ark-like-chat", messages=[ {"role": "system", "content": "你是某电商平台的客服助手,回答要简洁。"}, {"role": "user", "content": "我想改一下我的套餐。"}, ], ) print(resp.choices[0].message.content) print(resp.usage)这段代码里,模型收到的上下文只有 system 提示词和当前这一条用户问题,没有"上一轮聊了什么"的概念。API 本身是无状态的,模型不记得任何历史。体现在账单上,就是每次请求只花一份的钱,自然便宜。
从工程角度说,单轮请求的成本极低、响应速度快、并发安全也好做——不存在"两个人同时操作一个会话导致历史串位"的问题。这也是很多早期问答类产品能靠极低 Token 成本撑起大量流量的原因。
1.2 单轮的应用边界与多轮需求从哪来
但单轮有一个致命缺陷:它没办法处理"需要依赖上下文才能回答"的问题。
我举几个场景你就明白了。用户问"我的订单到哪了",单轮接口可以答;用户接着问"那退款什么时候到",如果模型不知道前一句里说的是哪笔订单,它就只能瞎猜。客服、销售、售后这类连续对话场景,模型必须知道用户前面说了什么。再比如 Agent 类应用:模型需要先调用搜索工具、再调用计算器、再根据结果做判断,每一步的结果都要放到下一轮输入里,否则整个流程根本走不下去。
所以多轮需求不是"锦上添花",而是从单轮问答走向真正的对话式产品的必经之路。但问题也随之处处而来:历史消息到底保留多少?每轮都发给模型,Token 账怎么算得过来?上下文窗口满了怎么办?
这也是为什么标题里把"从单轮到多轮"和"Token 成本"放在一起——这两件事本质上是矛盾的:轮数越多,历史越长,Token 消耗越猛;你既要让模型"记得住",又不想让账单"爆掉"。
2. 多轮会话的连接件:messages 数组、上下文窗口与逐轮膨胀
2.1 多轮不是"更聪明的单轮",而是把历史塞进同一请求
多轮对话的工程实现,最直白的方式就是把所有历史消息都放进messages数组,然后在每次请求时完整发送。伪代码大概是这样的:
history = [ {"role": "system", "content": "你是某电商平台的客服助手,回答要简洁。"}, ] def chat(user_input): history.append({"role": "user", "content": user_input}) resp = client.chat.completions.create( model="ark-like-chat", messages=history, ) assistant_reply = resp.choices[0].message.content history.append({"role": "assistant", "content": assistant_reply}) return assistant_reply这段代码看起来人畜无害,跑起来就知道痛了。假设第一轮你发的内容是 1000 token,第二轮请求时 messages 里已经有两轮的内容,大约 2000 多 token;第三轮 3000 多……每新增一轮,请求体积就线性增加。第 20 轮时,单次请求可能已经要发送 2 万 token 的历史内容。
这还没算更隐蔽的情况。如果中间夹着工具调用,messages 里还会混入 tool call 和 tool 返回结果,这些 JSON 动辄上千 token;如果用户上传了图片,图片还会被转成视觉 token,一张图可能吃掉几百到上千 token。历史体积的膨胀速度,远超你凭直觉的判断。
2.2 上下文窗口的硬边界:1M 窗口能装多少轮
模型能接收的上下文是有限的,这个上限就是上下文窗口。市面上常见的窗口有 8k、32k、128k、200k,以及标题里提到的 Ark 类长上下文模型的百万级窗口(1M 甚至更高)。窗口越大,能装的轮数越多,但成本也会跟着变。
先给一个直观的换算概念。中文在大模型分词器里大约是一个汉字 1 到 2 个 token,我按 1.5 来估算。假设一个典型的客服对话,用户每条问题 200 字,助手每条回复 400 字,一轮合计 600 字,折算下来大约 900 到 1000 token。再加上 system 提示词和一些转义符号,按每轮 1000 token 计算:
| 上下文窗口 | 去掉 system 和输出预留后可用空间 | 大概能装多少轮完整对话 |
|---|---|---|
| 8k | 约 6k | 约 6 轮 |
| 32k | 约 24k | 约 24 轮 |
| 128k | 约 110k | 约 110 轮 |
| 200k | 约 170k | 约 170 轮 |
| 1M | 约 800k | 约 800 轮 |
注意,这里的"能装多少轮"只是理论值。实际生产里,输出也需要占用窗口空间,你要给模型留出足够长的回复空间;工具调用结果、检索返回的文档碎片都会挤占空间。所以我会把可用窗口打个六七折来用,1M 窗口实际规划在 50 到 70 万 token 以内,不要真的跑到清算线边缘。
这里也顺便回答一个很多人问过的问题:"窗口越大,是不是我就可以无脑把所有历史都塞进去?"我个人的答案是:窗口大是底气,不是挥霍的理由。百万窗口虽然能装下几百轮历史,但模型对距离较远的早期内容注意力会衰减,而且这些历史每一轮都要付钱。
2.3 隐式压缩与显式截断:真实对话变成什么样
当消息超过窗口时,你会被迫做取舍。最常见的做法是直接把最早的几条消息丢掉,也就是"截断"。但这种做法有个隐蔽的问题:被截断的历史里,往往藏着用户最早的诉求和关键约定。
举一个我在某个客服 Demo 里遇到的真实情况。用户刚开始说"我要退掉 3 月 1 号下单的那双鞋",然后跟助手来回聊了 20 轮换货细节。如果按窗口截断,把最早几句删掉,第 21 轮时模型可能根本不记得"退鞋"这回事,面对用户问"所以你们什么时候上门取件",模型会一脸茫然。
所以上下文管理从来不是"等窗口满了再处理"这种事后动作,而是在设计多轮会话时就要想清楚:哪些信息必须长期保留,哪些信息过期就可以扔。这也是后面第四节要展开讲的核心内容。
3. Token 成本的三层拆解:单价、缓存与结构性浪费
3.1 输入与输出为什么价格不一样
先看计费模型。OpenAI 风格 API 的 Token 费用通常分两部分:输入 token(prompt 部分)和输出 token(completion 部分)。输出价格通常是输入价格的好几倍,因为生成是逐个 token 推理的,计算量远大于并行处理输入。
Ark 类长上下文平台通常选择另一个策略:把输入单价压得很低,鼓励你把大段历史、长文档都塞进去,然后按总 token 量收费。这意味着如果你做的场景是"长文本分析、超长对话、文档问答",长上下文模型会非常划算;但如果你做的是高频短对话,仍需要注意输出 token 的单价占比。
为了统一讨论,我定义一个虚拟计价模型,假设:
| 计费项目 | 虚拟单价(元 / 百万 token) |
|---|---|
| 输入 token | A |
| 输出 token | B(通常为 2A 到 4A) |
| 缓存命中 token | C(通常为 0.1A 到 0.2A) |
实际各家模型价格差异很大,但分析方法是一样的。你只要知道自己用的模型的 A 和 B 是多少,就能套公式算成本:
def estimate_cost(input_tokens, output_tokens, cached_tokens, price_in, price_out, price_cache): cost = ( input_tokens * price_in + output_tokens * price_out + cached_tokens * price_cache ) / 1_000_000 return cost3.2 缓存段带来的机会:前缀命中省 90%
很多 OpenAI 风格 API 现在支持一种隐式缓存机制:如果请求的前缀(通常是 system 提示词 + 开头几轮消息)与历史请求完全一致,这部分的输入 token 可以按缓存价格计费,大约是正常输入价的十分之一甚至更低。这对多轮对话来说是决定性的利好,因为前缀恰好是"每次都要重发但内容不变"的部分。
但要注意,缓存命中有几个前提:前缀必须逐字节一致,模型名一致,请求内容顺序一致。只要 system 提示词里夹了一个时间戳、随机数,或者你把前两轮的顺序调换了一下,缓存就可能失效,全部按正常输入价计费。
这也是我在踩坑后总结的一条铁律:system 提示词必须稳定,能不改就不改;即便要动态插入内容,也尽量把可变内容放到前缀之后。结构上让"不变的大块内容"尽量堆在前面,才能最大化命中缓存。
3.3 逐轮重发的累计成本对比:有无缓存的差距有多大
我用一个模拟算账的方式,把"从单轮到多轮"的成本膨胀量化出来。假设一个会话跑 100 轮,每轮新增内容折算 1000 token,system 固定 500 token,模型输出每轮平均 700 token。再看看两种策略:
- 策略一:不做任何处理,每轮发送全部历史,也没有缓存命中;
- 策略二:上下文前缀稳定,历史全部命中缓存,每轮只付新增内容的输入费用。
我不带具体价格,只看 token 量级:
| 指标 | 策略一(全量重发,无缓存) | 策略二(前缀缓存命中) |
|---|---|---|
| 第 1 轮输入 | 1500 | 1500(其中 1500 计费) |
| 第 50 轮输入 | 49500 | 1500 命中缓存 + 1000 新增计费 |
| 第 100 轮输入 | 99500 | 1500 命中缓存 + 1000 新增计费 |
| 累计输入 token | 约 500 万 | 约 10 万 |
| 累计输出 token | 约 7 万 | 约 7 万 |
看清楚这个差距了吗?同样是 100 轮对话,有缓存和没缓存,输入 token 差了接近 50 倍。策略一的 500 万 token 里,绝大部分是被反复重发的历史消息,真正"有价值"的新内容只有末尾那一小段。这就是我开头说"账单翻了几十倍"的原因——不是模型变贵了,是我把同样的历史反复买了 100 次。
3.4 为什么"贵"往往不是模型单价高,而是重复发送历史
把这句话单独拎出来说,是因为我觉得这是很多人对 Token 成本最大的误解。看到一个模型单价高,就急着换便宜模型;但实际上在多轮对话场景里,成本的炸弹藏在"重复"二字里。就算换成单价便宜的长上下文模型,如果你每一轮都原封不动地重发几百轮历史,账单照样起飞。
所以做成本优化的第一动作,永远是减少重复发送的量,而不是纠结那一点点单价差。单价是乘法因子,重复发送量是幂次放大,后者才是真正烧钱的地方。
4. 可落地的上下文管理策略:滑动窗口、摘要与分层切分
4.1 滑动窗口策略:只保留最近 N 轮
最简单也最不容易出错的策略,就是维护一个固定大小的窗口,只把最近 N 轮对话发给模型。窗口之前的消息不再进入 API 请求,但可以继续保存在本地数据库里,等用户明确问到的时候再按需捞回来。
MAX_ROUNDS = 8 SYSTEM_PROMPT = "你是某电商平台的客服助手,回答要简洁。" def build_messages(history, max_rounds=MAX_ROUNDS): system = {"role": "system", "content": SYSTEM_PROMPT} # 每轮有 user 和 assistant 两条消息,所以取最后 max_rounds*2 条 recent = history[-(max_rounds * 2):] return [system] + recent这种做法的优点很明显:请求体量可控,成本可控,模型对近期内容的注意力也更集中。缺点是会丢掉早期信息,那部分信息要么靠摘要承接,要么等用户主动提出再补充。
我在实际运营里还会加一个小细节:窗口截断处插入一句轻微说明。比如在第一条可见消息前加一条 system 或 assistant 消息:"注意:更早的对话内容已经被省略。如果用户提到的内容不在当前上下文中,请向用户确认。"这样能显著减少模型胡编乱造的概率。
4.2 分层记忆:用摘要压缩早期对话
滑动窗口解决的是"最近的对话怎么办",摘要解决的是"被扔掉的历史怎么保留"。
具体做法是:当一个会话的轮数达到阈值(我常用 12 到 20 轮),把当前所有历史交给模型,让它生成一段摘要,然后把摘要作为一条新的上下文记录,替换掉全部旧消息。下次请求时,messages 变成:system + 摘要 + 最近几轮明文对话。
def summarize_history(history): dialogue_text = "\n".join( f"{m['role']}: {m['content']}" for m in history ) prompt = f""" 请把下面的对话压缩成 200 字以内的中文摘要,保留: 1. 用户的身份与核心诉求 2. 已经达成的关键结论 3. 待办事项和承诺的时间点 4. 不能丢失的订单号、地址、金额等硬信息 对话内容: {dialogue_text} """ resp = client.chat.completions.create( model="ark-like-chat", messages=[{"role": "user", "content": prompt}], ) return resp.choices[0].message.content注意,摘要本身也是一次 API 调用,会产生输出 token。所以摘要不能每轮都做,合理阈值是"窗口快满时"或"对话达到一定轮数时"。另外,摘要会丢失细节,尤其容易丢数字和小约定。我的做法是:摘要只负责"语义记忆",把订单号、地址、金额等结构化字段剥离到会话元数据里,不走 LLM 摘要,直接存变量。这样即使模型对早期细节失忆,服务端也能在必要时把准确数据取出来回填。
4.3 存储侧兜底:把消息落库,按需抬回
先别急着把历史从 API 请求里删掉就完事——你得有地方找回来。
我的标准架构是三段式:消息流水全部落库;会话内存只放最近窗口的内容;当用户问到窗口之外的内容,或者系统检索判断需要早期信息时,再从库里取回相关片段拼进请求。这里可以简单用关键词匹配,也可以上向量检索;对大多数轻量场景来说,关键词匹配加时间就近排序已经够用。
比如用户第 30 轮问"我第一句话说的那款鞋到底能不能退",你用关键词"鞋"去库里捞最早的消息,把它重新拼到本次请求里,模型就能准确回答。这个思路比"把所有历史都塞给模型让它自己找"要便宜得多,也精准得多。
4.4 一条完整的成本降低流水线
把这些策略串起来,我现在的多轮请求处理流程大致是这样的:
请求入口 -> 读取会话元数据 -> 组装短期窗口(最近 8 轮明文) -> 判断是否需要摘要补充 -> 按需检索历史片段 -> 拼接最终 messages -> 统一调用 API -> 记录 usage 与费用每一步都在控制成本:
- 优先只发最近 8 轮明文,把请求体量压到最小;
- 会话超过阈值时,用摘要替换更早的明文,替代"无脑全量重发";
- 结构化字段(订单号、地址、价格、时间)全放会话元数据,不塞进上下文;
- 只有用户明确涉及早期信息时,才按需检索拼接,而不是每轮都带上所有历史;
- 请求发送前检查 messages 总 token 量,超过预算直接降级为"摘要+最近一轮"。
这五步走完,我的多轮会话成本相比最初版本降了大约 80%。具体降多少取决于对话长度分布,但思路是通用的。
5. 观测与迭代:让 Token 成本变得可视化
5.1 从 response 的 usage 字段读取 token 数
不观测就谈不上管理。好在 OpenAI 风格 API 每次响应都会带上 usage 字段,这是我最先用来做数据分析的地方:
{ "usage": { "prompt_tokens": 18234, "completion_tokens": 765, "total_tokens": 18999, "prompt_tokens_details": { "cached_tokens": 15200 } } }注意cached_tokens这个字段不是所有模型都返回,但如果有,一定要记下来。它能直观告诉你:这次请求里有多少输入 token 走了缓存低价通道。如果这个值长期偏低,说明你的 system 提示词可能不稳定,或者请求前缀变化太频繁,缓存命中率出了问题。
5.2 自家日志里的三张表
光看单次响应的 usage 还不够,我会在服务端把请求日志沉淀下来,至少维护三张非常简单的表:
| 表 | 记录内容 | 主要用途 |
|---|---|---|
| 请求明细表 | 会话 ID、请求 ID、模型、输入 tokens、输出 tokens、缓存 tokens、估算成本、耗时 | 按天核对费用,定位异常高耗请求 |
| 会话汇总表 | 会话总轮数、累计 tokens、累计成本、是否触发摘要、是否触发截断 | 看单个用户的成本上限是否失控 |
| 模型损耗表 | 按模型维度聚合日均 token 量、平均成本、缓存命中率 | 决定要不要换模型或调 prompt |
这个表不用做得很复杂,关键是数据要持续记录。我第一次做完这三张表,就发现自己某条线上其实只有百分之二三十的高耗会话,把它们的成本压下来,整体立刻就降了。
5.3 压测与基线:先定一个能接受的单次成本上限
在把多轮功能上线前,我会跑一轮多轮对话压测。方法很简单:准备一批模拟对话脚本,按照"用户提问 -> 模型回答 -> 再提问"的节奏连续跑 50 轮、100 轮,把每一轮的 prompt_tokens、completion_tokens 打点记录。
压测之后给两个基线值:单次请求成本上限,以及单会话累计成本上限。比如我会定"单次请求成本不超过 0.01 元,单个会话累计不超过 0.5 元",超过就强制触发摘要压缩或提示用户开新会话。这样做不是抠门,而是防止出现"用户挂机一整晚,服务端默默重发了 500 轮历史"这种异常账单。
另一个压测时值得关注的点是耗时。发送的 tokens 越多,响应延迟越高——尤其长上下文模型,token 一大,首字延迟明显上升。所以压测时除了看成本,还要记录首 token 延迟。很多时候压 Token 预算本身就是压响应速度,这两件事是一起影响的。
6. 一些容易被忽视的 Token 隐性消耗
6.1 工具调用结果和 Function Call 的 JSON 开销
做 Agent 类多轮时,messages 里会有大量 tool 消息。每个工具参数是 JSON,工具返回也是 JSON,一轮工具调用可能轻易吃掉 1000 到 3000 token,而且这些 token 还不是用户能看到的对话内容。
我的建议是:工具返回只保留必要字段。很多外部 API 会返回一长串无关字段,直接全文塞给模型就是浪费。用一段简单的清洗函数把返回体精简到模型真正需要判断的字段,Token 消耗能立刻降下来。
6.2 多轮里被反复带上的图片与长文档
如果用户上传了一张图片,OpenAI 风格接口通常会把图片转成视觉 token,一张图大约几百到上千 token,按窗口内的位置反复计费。长文档同理,一份几十页的 PDF 塞进上下文就是几万 token。
处理方式跟文本一样,不能被"窗口足够大"迷惑。图片只在有明确引用需求的最近几轮里保留,超过阈值就把图片描述文字化,存在会话元数据里,不再跟着每一轮请求走。长文档则优先走检索,取出相关片段而不是全文重发。
6.3 重试机制和请求异常导致的双倍消耗
还有一个账,很多人不算。当一次请求因为超时重试,或者因为用户断线导致响应没有接收到、然后客户端又发了一遍同样的请求,这部分重复消耗是不在正常对话轮数统计里的。我在日志里看到过同一条 prompt 连续打三次 API 的案例,账单白付了两倍。
所以生产环境一定要做幂等控制:同一轮请求只发一次,失败了最多重试有限次数,并且重试之前确认上一次请求没有成功返回。更稳妥的做法是把模型的最后一条 assistant 回复缓存下来,用户重连时直接读缓存,不重新调用模型。
7. 我的取舍原则:长上下文模型不是万能药
最后想聊聊我对"要不要无脑上大窗口"的判断。长上下文模型(Ark 那类百万窗口、低价输入)确实给了我很大的想象空间,很多场景里我可以直接把整本手册丢进上下文做问答,不用自己做检索。但对于高频多轮产品,我仍然坚持混合策略:
长文本分析、文档问答、一次性长输入的任务,用长上下文模型,发挥低输入单价优势;高频客服、Agent 多轮,用小窗口模型加缓存加滑动窗口,把逐轮成本压到最低;复杂推理的最终生成,用更强的高单价模型,但只传给它精简后的必要上下文。
这个策略的核心逻辑是:Token 成本分析不只是一道乘法题,更是一道选择题——不同任务的上下文构成完全不同,你要为每个任务选择匹配的模型和匹配的上下文策略。
做这一整套改造的过程中,我最大的体会是:上下文管理不是"窗口快满了才想怎么办"的应急方案,而是从产品设计第一天就要考虑的架构问题。你把 system 提示词想得越稳定,缓存命中率就越高;你把会话元数据与模型上下文分离得越彻底,Token 浪费就越少;你记录 usage 越勤快,异常账单就越早暴露。
最后再分享一个小技巧:每次调整完上下文策略,不要只看总费用降了没有,要看"每次请求的输出 token 占总 token 的比例"。这个比例越高,说明你发给模型的输入里,有效信息占比越大。如果这个比例长期萎缩,说明你的历史仍然太重,还没压到位。