在聊天 API 的 assistant turn 中预先塞入几个 token,模型会将其视为自己生成的继续输出,从而绕过"Sure! Here's..."等固有开场白。只需在消息数组加一条消息,就能让 JSON 等结构化输出干净无前缀。
A chat API 调用不是一次提问,而是一份对话记录:一条 system turn、一条 user turn,然后是一条由模型填入的空 assistant turn。
Assistant prefill(也称为 response priming)是指由你自己来写 assistant turn 的前几个字符。模型无法区分你的 tokens 和它自己的 tokens,所以它只会继续往下生成。
这就是整个技巧的全部:在数组中多添加一条消息。
向 Chat 模型请求 JSON,你会得到这样的结果:
Sure! Here's the extracted data as JSON:
json { "order_id": "A-1042", "customer": "Priya Nair", "total": 129.5, "status": "shipped" }
Let me know if you need any other fields!
内容是完美的。外壳却是一场灾难。JSON.parse 抛出异常,你的正则表达式抓错了行,markdown 围栏还得手动去掉。
这个开场白不是 bug。Chat 模型训练在 helpful-assistant 对话数据上,所以对于空的 assistant turn,最高概率的开头确实是"Sure! Here's...",然后是一个围栏和一个友好的结束语。这正是让模型在对话中显得亲切友好的原因。但当程序是读者时,这就成了 bug。
## 提问与预填充
你可以在 prompt 中加"仅返回 JSON,不要前言"。这有帮助。但这也只是一个请求,采样器可以自由忽略,而当下游有解析器时,一千次调用中出现一次杂散的"Certainly!"就是一次生产事故。
Prefill 不与这种习惯竞争。它直接消除了这种习惯:
const messages = [
{ role: "user", content: Extract order_id, customer, total, status as JSON.\n\n${doc} },
{ role: "assistant", content: '{\n "order_id": "' } // <- the prefill
];
没有 prefill 参数。你只需在数组末尾追加一条 role 为 assistant 的消息。数组中其他位置的 assistant 消息是普通的历史记录——few-shot examples 就是这样工作的。只有末尾的那条才是 prefill,因为只有最后一个 turn 正在被生成。
{ "order_id": "A-1042", "customer": "Priya Nair", "total": 129.5, "status": "shipped" }
## 为什么它有效:继续生成,而非重新开始
模型是自回归的。它基于上下文中的每个 token 进行条件生成,包括你写入 assistant turn 的那些 token,而且它无法知道你写了这些 token。所以它不会重新规划答案——它只会完成你开的那个头。
把 prefill 放在数组中间,它会生成下一个元素。把 prefill 放在字符串中间,如上所示,那么生成的第一个 token 就是 order ID 本身,因为一个未终止的 JSON 字符串唯一合理的延续就是其内容。
格式是自增强的。一旦响应以 `{` 开头,可能的延续就是 JSON key。一旦以 `-` 开头,就是另一个列表项。一旦以 `SELECT` 开头,就是 SQL 而不是关于 SQL 的说明文字。所以整个输出格式之争实际上是第一个 token 之战——而 prefill 通过法令赢得了这场战争。
这就是为什么一个字符的 prefill 通常能打败三段格式说明。
## 选择只有一个合理延续的最短前缀
更长不一定更好。更长开始断言内容,而这正是危险的边缘:任何你预填充的内容都会被断言为真,无论它是否真的是真的。为一个没有 status 的记录 prefill `"status": "`,模型会编造一个而不是打断你开始的语法。它不能反驳你,只能继续你。
搭建结构的骨架。把每个值留给模型。
## 三个伴随它的规则
**配合 stop sequence 使用。** Prefill 控制 turn 如何开始;stop sequence 控制它在哪里结束。只用 `Label:`,模型会写 `Label: negative` 然后继续解释。加上 `\n` 作为 stop sequence,响应就正好是 `Label: negative`。
**在解析之前重新附加 prefill。** 大多数 API 只返回生成的文本——你发送的 prefill 不会被回显:
const PREFILL = "Label:"; const res = await call({ messages, prefill: PREFILL, stop: ["\n"] }); const full = PREFILL + res.text; // "Label: negative" // ^^^^^^^ the API returned only " negative"
**永远不要用空白字符结束 prefill。** 你的文本和第一个生成的 token 之间的连接是一个真正的 token 边界。`"Label: "` 加上 `" negative"` 会得到 `"Label: negative"`——两个空格,精确匹配失败。一行代码就能修复:`prefill.replace(/\s+$/, "")`。
还有一个安全注意事项:因为模型会把你写的内容当作它自己写的内容继续,所以 prefill 是 API 暴露的最直接的操控杠杆。它是开发者拥有的文本,与 system prompt 同等重要。永远不要让最终用户提供 prefill。
## 一个引导,不是保证
Prefill 转移概率。它不限制采样器的词汇表。模型仍然可以提前关闭 JSON、发出尾部逗号,或者在长生成中漂移回说明文字。
语法约束解码是更强大的东西:JSON 模式、结构化输出和严格的 tool schema 屏蔽 logit,使得无效 token 实际上不可能被采样。Prefill 永远无法做出这样的承诺。无论如何都要保留 try/catch。
## 它今天的现状
值得在这里精确说明,因为它已经改变了。
在当前的 Claude 模型上——Opus 4.6 及以上、Sonnet 4.6 及以上,以及 Fable 5——末尾的 assistant turn 返回 400。Prefill 作为一种功能被移除了。数组中其他位置的 assistant 消息不受影响。
它的工作被拆分并变得更强:
response = client.messages.create( model="claude-opus-5", max_tokens=1024, output_config={"format": {"type": "json_schema", "schema": ORDER_SCHEMA}}, messages=[{"role": "user", "content": f"Extract the order fields.\n\n{doc}"}], )
JSON 的 schema、带有 enum 的分类标签 tool,以及一行"直接回复,不要前言"的 system instruction。这是一个真正的升级——schema 是被强制执行的,而不是被引导的,而且没有 prefill 需要重新附加。
与此同时,这个机制本身在你控制原始 prompt 字符串的任何地方都仍然存活并原生活跃,这是大多数本地和自托管运行时的现状。在那里,"prefill"只是 prompt 在 assistant turn 中途结束——这才是它一直以来的样子。
无论如何都要学习它。这是我所知道的关于为什么第一个 token 拥有整个回复的最清晰的论证。
## 交互版本
我建了一个页面,你可以在那里观看这个运行:一个确定性解码器,逐 token 发出 assistant turn,通过实际扫描迄今为止的文本来选择每一步——引号是否打开、大括号嵌套深度如何、哪些 schema key 已经出现、markdown 围栏是否打开。编辑 prefill 真正改变了解码路径,而不是选择一个 canned answer。
四个任务,14 个真实验证器(JSON.parse、精确标签匹配、项目符号行解析、SQL 形状)。Baseline 4/14,prefill 14/14——Baseline 通过的四个任务在每种情况下都是内容检查。模型每次都知道答案。它输在 envelope 上。
还有一个实时的 stop-sequence 字段,这样你可以通过清除它来打破精确匹配检查,还有一个尾部空格警告,显示双空格 bug 的实际情况。
https://dev48v.infy.uk/prompt/day59-assistant-prefill.html
Day 59 of PromptFromZero——每天一个 prompt 工程技巧,从零开始构建,无需 API key。