先把上限算清楚
三个数字决定一切:
- 上下文总量 100,000 token,prompt 与 completion 合计。
max_tokens默认 2048,单次请求最大 16,000。- 请求体不超过 8 MB,这个上限对纯文本基本碰不到,真正先到顶的是 token。
预算公式很简单:prompt 可用 = 100000 - max_tokens。你打算让模型一次输出 4000 token,prompt 最多就只剩 60,000。超出时接口返回 400,不会替你自动截断。所以“把整本书塞进去再说”不是策略,只是赌运气。
另一个常见误解:max_tokens 设得越大越保险。其实它是预留的输出空间,设 16,000 就意味着 prompt 只能有 48,000。按需设置,别无脑拉满。
举个算账的例子,假设你有一份 30 万字的访谈整理稿要总结。按每字 1.3 token 粗估,约 39 万 token,是上限的六倍多,无论如何都不能整份送进去。再假设单次摘要要 800 token 的输出,那每块输入最多 63,000,实际再留一成余量,也就五万多。但这还不是最优解,块太大时模型对中段内容的关注会变弱,所以下面的做法是把块切小,宁可多调几次。
token 估算:近似算法
没有本地分词器时,可以用下面的经验值做粗估。以下均为近似,并非精确值,不同文本差异可达一到两成:
| 文本类型 | 近似换算 |
|---|---|
| 中文正文 | 约每字 1 到 1.5 token |
| 英文 | 约每 4 个字符 1 token,或每词 1.3 token 左右 |
| 代码、JSON、符号密集文本 | 比普通文本更耗 token,建议按偏保守的高值估 |
一个够用的粗估函数如下,并带上留余量的预算计算:
CONTEXT = 100000
def est_tokens(text: str) -> int:
"""粗估:中文按每字 1.3 token,其余字符按每 4 个字符 1 token。仅为近似。"""
zh = sum(1 for c in text if "\u4e00" <= c <= "\u9fff")
other = len(text) - zh
return int(zh * 1.3 + other / 4) + 1
def max_prompt_budget(max_tokens: int, margin: float = 0.1) -> int:
"""给定计划的 max_tokens,返回 prompt 最多能用多少 token(留 margin 余量)。"""
return int((CONTEXT - max_tokens) * (1 - margin))
# 例:计划让模型写 3000 token,prompt 预算
print(max_prompt_budget(3000)) # 54900校准方法:随便取一段典型输入发一次请求,读响应里的 usage.prompt_tokens,和你的估算对比,算出自己业务数据的真实倍率,然后把系数改成实测值。估算只用来决定“要不要分段”,不用来对账,对账看 usage。
长文档总结:分段加合并
文档超过预算时,标准做法是 map-reduce:先分段各自概括,再把概括合并成总摘要。注意以下细节:
- 按段落边界切,不要按固定字数硬切,否则会把句子拦腰截断。
- 每块预算留足,建议不超过上限的三分之一,给指令和输出留空间。
- 分段摘要要求保留人名、数字、关键事件,否则合并时信息已经丢了。
- 摘要的摘要仍然太长时,再递归一层。
import os
from openai import OpenAI
client = OpenAI(base_url="https://api.wushenchaapi.com/v1", api_key=os.environ["API_KEY"])
def split_paragraphs(text, budget):
"""按段落切块,每块估算不超过 budget token。"""
chunks, cur, used = [], [], 0
for p in text.split("\n"):
t = int(len(p) * 1.3) + 1 # 中文粗估
if used + t > budget and cur:
chunks.append("\n".join(cur))
cur, used = [], 0
cur.append(p)
used += t
if cur:
chunks.append("\n".join(cur))
return chunks
def ask(prompt, max_tokens=800):
r = client.chat.completions.create(
model="uncensored",
temperature=0.3,
max_tokens=max_tokens,
messages=[{"role": "user", "content": prompt}],
)
return r.choices[0].message.content
def summarize_long(text, budget=12000):
parts = split_paragraphs(text, budget)
partial = [ask("用不超过 200 字概括下面这段,保留人名和关键事件:\n\n" + p) for p in parts]
merged = "\n".join(f"第{i+1}部分:{s}" for i, s in enumerate(partial))
return ask("下面是分段摘要,请合并成一份 400 字以内的整体摘要:\n\n" + merged, max_tokens=900)这里 temperature 取 0.3,总结类任务要稳定。每段调用互相独立,可以并发,但注意每把 key 每分钟 300 次请求的限制,几十段的文档完全不会碰到。
块的大小怎么定,没有标准答案。经验上,一万到一万五千 token 一块,兼顾了细节保留和调用次数。拿一份样本分别用 5000、12000、25000 三种块大小跑一遍,对比摘要里漏掉的关键事实数量,就能选出适合你文档类型的值。合同、技术文档这类信息密度高的材料,块要更小;对话记录这类冗余多的,可以更大。
小说续写:滑动窗口加滚动大纲
写到第十几章时,全文已经放不下,也没必要全放。思路是两层记忆:
- 近景:最近两三章原文,保证文风、对话节奏和场景细节连贯。
- 远景:更早章节压缩成大纲,保留人物关系、伏笔和未解决的线索。
每写完一章,就让模型把这一章概括成三五行追加进大纲;大纲本身超过 3000 token 时,再整体压缩一次。
def continue_story(chapters, outline, new_hint, keep_last=3):
"""chapters: 已写章节列表。只带最近 keep_last 章原文,更早的用 outline(滚动大纲)代替。"""
recent = "\n\n".join(chapters[-keep_last:])
prompt = (
f"【全书大纲(早期章节摘要)】\n{outline}\n\n"
f"【最近章节原文】\n{recent}\n\n"
f"【下一章要求】\n{new_hint}\n\n请写下一章,约 2000 字。"
)
return ask(prompt, max_tokens=4000)伏笔是最容易丢的东西。建议在大纲里单列“未回收的伏笔”一栏,续写时明确要求本章处理其中一条。人物名、地名也列成固定清单放在 system 里,避免滑出窗口后模型自己改名。
续写时还有一个容易被忽视的问题:风格漂移。模型会越写越贴近最近几章,如果前面章节的风格和后来不一致,就会放大。办法是在 system 里写一段“文风样本”,挑一段你最满意的原文放进去,作为固定锚点,不随窗口滑动。
max_tokens 与截断
回答被截断有两种情形,要分清:
finish_reason为length:撞上了你设置的 max_tokens。解决办法是调大 max_tokens,或者让模型分段写,“写到这里先停,我说继续再写”。- 请求直接 400:prompt 加 max_tokens 已超过 100,000。解决办法是缩短输入或调小 max_tokens。
一个实用的预算对照:
| 任务 | 建议 max_tokens | prompt 可用(约) |
|---|---|---|
| 摘要、抽取 | 800 | 63,200 |
| 单章续写(约 2000 字) | 4000 | 60,000 |
| 万字级长输出 | 16000(上限) | 48,000 |
长输出同时受“响应时间”影响,建议配合流式输出,边生成边显示。
续写被截断时,不要简单把半截内容丢给模型说“继续”。更稳的做法是把已生成的部分作为 assistant 消息放回 messages,再加一条 user 消息“从上一句话之后接着写,不要重复”,这样衔接最自然。注意这一步会让 prompt 变长,要重新核对预算。
长上下文上线清单
- 每次请求前用估算函数算 prompt,超预算就走分段分支。
- 每次响应记录 usage,持续校准估算系数。
- system 与大纲放在内容最前,指令里的关键要求在最后重申一遍。
- 检查 finish_reason,是 length 就告警或续写。
- 历史对话设保留上限,超限时丢弃最旧的,不要等接口报 400。
相关内容:提示词结构见 Prompt 写法,报错处理见错误码排障手册,计价见价格页。
最后提醒一点:长上下文不等于长记忆。模型每次请求都是从零读你给的内容,不会记得上一次调用的任何事,需要连续性就必须你自己把历史带上。