BUZZ AI Gateway
文档 · 实战示例 · 长文档问答

长文档问答

把长文档塞进 Claude 的 200K 上下文,标记缓存,流式输出答案。第一轮之后的每一个追问都按 1/10 输入价命中缓存。

POST https://buzzai.cc/v1/messages
什么时候用这个模式。 单文档 5K-200K token、一次会话里被反复问:合同、RFC、手册、代码库导出文本、会议纪要。如果语料是百万级 token,先做检索把范围切下来,再对切片用这个模式。

请求结构

三层,顺序固定:

  1. System block — 怎么回答的指令(可缓存)。
  2. System block — 文档本身,带清晰分隔符(必缓存)。
  3. User message — 实际问题(不缓存)。
{
  "model": "claude-sonnet-4-6",
  "max_tokens": 1024,
  "stream": true,
  "system": [
    {
      "type": "text",
      "text": "你只能基于下面文档回答。文档里没有的内容直接说没有。涉及来源时原文引用。"
    },
    {
      "type": "text",
      "text": "<DOCUMENT name=\"contract.pdf\">\n... 25,000 tokens 的文档文本 ...\n</DOCUMENT>",
      "cache_control": {"type": "ephemeral"}
    }
  ],
  "messages": [
    {"role": "user", "content": "终止条款是什么,需要提前多少天通知?"}
  ]
}
命中缓存要前缀字节级一致。 指令块如果永不变,可以不加 cache_control(它会作为前缀的一部分被一并缓存,直到最深的 cache breakpoint)。关键是:文档块跨调用必须字节一致。如果你把用户特定数据拼进文档,缓存会失效。

选模型

模型适用
claude-haiku-4-5-20251001FAQ 式查询、短文档(<10K tokens)、高 QPS 问答。
claude-sonnet-4-6默认。长文档、需要多步推理。
claude-opus-4-7最难的分析题:横向对比、矛盾检测、跨文档综合。最难场景开 thinking

完整流式样例

"""
长文档问答,带流式。文档第一次写入缓存,后续每个问题命中缓存。
依赖:pip install anthropic
"""
import os, pathlib
from anthropic import Anthropic

client = Anthropic(
    base_url="https://buzzai.cc",
    api_key=os.environ["BUZZ_API_KEY"],
)

DOC_PATH = pathlib.Path("contract.txt")
DOC_TEXT = DOC_PATH.read_text()

INSTRUCTIONS = (
    "你只能基于下面文档回答。"
    "文档里没有的内容直接说没有。"
    "涉及来源时原文引用。"
)

def system_blocks():
    return [
        {"type": "text", "text": INSTRUCTIONS},
        {
            "type": "text",
            "text": f'<DOCUMENT name="{DOC_PATH.name}">\n{DOC_TEXT}\n</DOCUMENT>',
            "cache_control": {"type": "ephemeral"},
        },
    ]


def ask(question: str):
    print(f"\nQ: {question}\nA: ", end="", flush=True)
    usage = None
    with client.messages.stream(
        model="claude-sonnet-4-6",
        max_tokens=1024,
        system=system_blocks(),
        messages=[{"role": "user", "content": question}],
    ) as stream:
        for text in stream.text_stream:
            print(text, end="", flush=True)
        final = stream.get_final_message()
        usage = final.usage
    print()
    print(
        f"  [usage] input={usage.input_tokens} "
        f"cache_create={usage.cache_creation_input_tokens} "
        f"cache_read={usage.cache_read_input_tokens} "
        f"output={usage.output_tokens}"
    )


if __name__ == "__main__":
    ask("终止条款是什么,需要提前多少天通知?")
    ask("有没有竞业限制?期限多长?")
    ask("用三条要点总结赔偿条款。")
// 长文档问答 + 流式。文档第一次写入缓存。
// 依赖:npm i @anthropic-ai/sdk
import Anthropic from "@anthropic-ai/sdk";
import { readFileSync } from "node:fs";

const client = new Anthropic({
  baseURL: "https://buzzai.cc",
  apiKey: process.env.BUZZ_API_KEY,
});

const DOC_PATH = "contract.txt";
const DOC_TEXT = readFileSync(DOC_PATH, "utf8");

const INSTRUCTIONS =
  "你只能基于下面文档回答。" +
  "文档里没有的内容直接说没有。" +
  "涉及来源时原文引用。";

function systemBlocks() {
  return [
    { type: "text", text: INSTRUCTIONS },
    {
      type: "text",
      text: `<DOCUMENT name="${DOC_PATH}">\n${DOC_TEXT}\n</DOCUMENT>`,
      cache_control: { type: "ephemeral" },
    },
  ];
}

async function ask(question) {
  process.stdout.write(`\nQ: ${question}\nA: `);
  const stream = client.messages.stream({
    model: "claude-sonnet-4-6",
    max_tokens: 1024,
    system: systemBlocks(),
    messages: [{ role: "user", content: question }],
  });

  for await (const event of stream) {
    if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
      process.stdout.write(event.delta.text);
    }
  }
  const final = await stream.finalMessage();
  console.log(
    `\n  [usage] input=${final.usage.input_tokens} ` +
      `cache_create=${final.usage.cache_creation_input_tokens} ` +
      `cache_read=${final.usage.cache_read_input_tokens} ` +
      `output=${final.usage.output_tokens}`
  );
}

await ask("终止条款是什么,需要提前多少天通知?");
await ask("有没有竞业限制?期限多长?");
await ask("用三条要点总结赔偿条款。");

usage 长这样

Q: 终止条款是什么...
A: 任一方提前 60 天书面通知即可终止...
  [usage] input=22 cache_create=24187 cache_read=0 output=98

Q: 有没有竞业限制...
A: 第 8.2 条规定 12 个月竞业限制...
  [usage] input=22 cache_create=0 cache_read=24187 output=84

Q: 用三条要点总结赔偿条款...
A: - 双方在知识产权侵权方面互相赔偿...
  [usage] input=22 cache_create=0 cache_read=24187 output=120

第一次调用把 24K token 写入缓存。后续每次都按输入价的 10% 读 24K。三次调用总共只花~1.2 倍的单次输入成本,而不是 3 倍

文档预处理

用分隔符包裹

Claude 对显式边界很敏感。用清晰的开闭标签 + name 属性,模型能反向引用给你:

<DOCUMENT name="employment-agreement-2026.pdf">
... 文本 ...
</DOCUMENT>

多文档

2-3 个文档时,直接用各自的标签拼接,都放在同一个缓存块里:

<DOCUMENT name="contract-a.pdf">...</DOCUMENT>

<DOCUMENT name="contract-b.pdf">...</DOCUMENT>

几十个文档时,改用「先检索后缓存」:每次问题取 top 3-5 块,塞进 system,接受检索集变化时缓存会部分失效。

PDF 和 Office 文件

先转文本。常见管线:pdftotextpandoc,或者 pypdf / pdfjs-dist 之类的库。把页眉页脚和页码去掉,这些东西污染上下文还会影响引用。

流式输出

stream: true。Claude 输出 SSE 事件;SDK 提供 text_stream 迭代器,只吐可见文本。最终 usage 在结尾通过 get_final_message() / finalMessage() 拿到。

不用 SDK 的客户端看到的原始 SSE:

event: message_start
data: {"type":"message_start","message":{"id":"msg_...","usage":{"input_tokens":22,"cache_creation_input_tokens":24187,"cache_read_input_tokens":0,...}}}

event: content_block_start
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}

event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"任一方"}}
...
event: message_delta
data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":98,...}}

event: message_stop
data: {"type":"message_stop"}

Cache TTL

Anthropic 默认 ephemeral 缓存自最后一次命中后存活 5 分钟。如果用户连续问问题,基本够用,每次新问题都会刷新计时器。会话有较长停顿时,选 1 小时 TTL:

"cache_control": {"type": "ephemeral", "ttl": "1h"}

1 小时 TTL 写入价高一些(约 5 分钟版的 2 倍),但读价相同。会话间隔超过 5 分钟时值得开。

让回答有据可查

两个能稳定降幻觉的 prompt 模式:

模式 1:不在文档里就拒答

如果文档里没有答案,只回复:
"NOT_IN_DOCUMENT: <简短原因>"

不要用文档之外的知识。

模式 2:引用原文

回答里每一个论断后面必须跟一段文档原文,
用 <quote>...</quote> 标签包裹。
没有原文支持的论断,不要写。

高风险场景(法务、合规)两个一起用。面向消费者的摘要,模式 1 单独够用。

什么场景不适合

场景更好做法
语料 > 200K tokens先做检索(BM25 或 embeddings),再用本 recipe。看 /v1/rerank 精排候选。
文档每次请求都变别缓存。create 成本会吃掉所有读节省。
同文档,不同用户,5 分钟内缓存是跨用户共享的,key 一样就命中。保持前缀字节级一致即可。
文档含用户 PII把用户特定数据拼到 user 消息,不要拼进缓存的 system 块。这样 cache key 稳定,用户数据也不会进入长寿命缓存。

相关链接