逐日AI
第 1 周 · D2约 4 小时

长文档、多模态与 API 初见:大上下文怎么用、prompt caching 省钱、PDF 与图片输入、带引用回答;Messages API 最小调用

第一次用代码调 Claude:把一份 PDF 整本喂进去,让它给出带页码引用的摘要,并用 prompt caching 把反复提问的成本降一个量级。

今日目标 0/3

登录后可以勾选并保存进度。

今日目标

  1. 能用官方 SDK 发出一次 Messages API 调用,并读懂返回里的内容块与 usage
  2. 能把 PDF 或图片作为内容块传给 Claude,并开启 citations 拿到带页码的引用
  3. 能说清 prompt caching 省在哪、什么情况下反而不省,并从 usage 字段验证缓存是否命中

昨天学的是怎么跟搭档交代事情。今天开始给它递材料——而且是一整本几十页的材料。读完做完实验,回到顶部勾掉三条目标。

小白版讲解

Messages API 的最小调用:一次请求里有哪几样东西

昨天的代码已经悄悄用过一次 API,今天正式拆开看。你和搭档的每一次交流,在 API 层面就是一次对 Messages API 的 HTTP 请求。请求里最少有四样东西:用哪个模型(model)、最多让它说多少(max_tokens)、名片(system,可选)、对话记录(messages)。响应回来也不是一段文字,而是一个内容块数组content),每个块有自己的 type:文本是 text,模型要调工具时是 tool_use,开了思考时还会有 thinking 块。你要的那句话,是数组里 typetext 的块。

响应末尾还挂着一个 usage 对象,记录这次请求进了多少输入 token、出了多少输出 token。这是今天最重要的字段,因为今天讲的每一个技巧,最后都要靠它来验证「到底省了没有」。先把最小调用写出来,把 usage 打印出来看一眼:

hello.ts
import Anthropic from '@anthropic-ai/sdk'
 
const client = new Anthropic() // 读 ANTHROPIC_API_KEY
 
const res = await client.messages.create({
  model: 'claude-sonnet-5',
  max_tokens: 512,
  system: '你是一名耐心的技术写作者,回答不超过三句话。',
  messages: [{ role: 'user', content: '一句话解释什么是上下文窗口。' }],
})
 
// content 是块数组,不是字符串
const text = res.content
  .filter((b) => b.type === 'text')
  .map((b) => b.text)
  .join('')
console.log(text)
 
// usage 是今天的主角:每个技巧最后都靠它验证
console.log(`input=${res.usage.input_tokens} output=${res.usage.output_tokens}`)
console.log(`stop_reason=${res.stop_reason}`) // end_turn / max_tokens / tool_use …

两个细节现在就记住。第一,stop_reason 告诉你模型为什么停:end_turn 是说完了,max_tokens 是被你设的上限截断了——摘要被截断一半却当成完整结果,是新手最常见的静默错误。第二,max_tokens 不要抠得太小,它是硬上限不是目标长度,模型不会因为你给了 512 就把话说得更精炼,只会被剪断。

大上下文不等于免费:窗口越大,越要算每次请求搬了多少

Claude 当前主力模型的上下文窗口(context window)是一百万 token 级别,一份几十页的 PDF、几万行代码整本塞进去都放得下。很多人第一反应是「那就不用管长度了」——这是今天要纠正的第一个直觉。

回到搭档的类比:你给他一箱材料,他每回答你一个问题,都得把整箱材料从头到尾重新翻一遍。模型是无状态的,每次请求都从零开始读你发过去的所有东西,输入 token 按次计费。一份 60 页的 PDF,每页大约要 1500 到 3000 个 token,整本大约 10 万 token;你围着它问十个问题,就是 100 万输入 token——即使单价便宜,也是一笔很实在的钱,更别说每次都要等它把 10 万 token 读完才开始回答。

窗口大解决的是「放得下」,没有解决「每次都要重读」。所以大上下文时代的基本功不是「怎么塞更多」,而是算清每次请求搬了多少、哪些是重复搬的。今天后面两个技巧——citations 和 prompt caching——一个让你搬得有据可查,一个让重复搬的部分只付零头。

上下文窗口是怎么被塞满的1/5
已用 20 / 100 token
system 人设20 tok
上下文窗口就是模型的桌面,大小固定。system 人设先摆上去,它通常要一直留着。

PDF 与图片作为内容块:模型看到的不只是文字

上一节说「把 PDF 塞进去」,具体怎么塞?不是先用工具把 PDF 转成纯文本再贴进提示词——那样表格会散、图表会丢。Messages API 允许你在 messages 的内容里直接放一个 document 块,source 是 PDF 的 base64;图片同理,放一个 image 块。Claude 对 PDF 的每一页既读文字也看版面,所以图表、表格、手写批注它都能理解。

几个硬性限制要记住:单次请求最大 32 MB,最多 600 页(上下文窗口不到一百万 token 的模型是 100 页),不能是加密的 PDF。特别密的 PDF(小字、复杂表格、大量图片)可能不到页数上限就把窗口填满,这时要按章节切开。另一个建议来自昨天的「材料在前、指令在后」:document 块放在 text 块前面,先让模型看材料,再看问题。

同一个 PDF 会反复问,所以每次都传 base64 也很浪费带宽。官方另有 Files API 可以先上传拿到 file_id,之后请求里只传 id——今天的实验用 base64 是为了少一个概念,生产里两种都行。

带引用回答:citations 让每句话都能指回页码

搭档读完 60 页给你一段摘要,你最关心的问题是:「这句结论是哪一页说的?」如果他只是凭记忆复述,你得自己翻回去核对;如果他在每句话后面标了页码,你只需要抽查。

以前做「带引用」只有一条路:在提示词里要求模型「引用原文并注明页码」,然后期待它老实抄。问题是模型抄的时候会「顺手改写」,页码也可能记错——你没法区分「它真的引用了」和「它觉得自己引用了」。Claude 的 citations 功能把这件事从「提示词约定」变成了「API 特性」:给 document 块加上 citations: { enabled: true },返回的文本块就会被切成多段,每段带一个 citations 数组,里面有被引用的原文(cited_text)、在第几份文档(document_index),以及位置——对 PDF 来说是 page_location,带 start_page_numberend_page_number(页码从 1 开始,结束页不包含)。

这些引用是 API 在服务端解析并核对过的,指向的一定是文档里真实存在的段落;而且 cited_text 不计入输出 token,比让模型自己抄原文还便宜。把它接到代码里:

cite.ts
import fs from 'node:fs'
import Anthropic from '@anthropic-ai/sdk'
 
const client = new Anthropic()
const pdf = fs.readFileSync('report.pdf').toString('base64') // base64 不能有换行
 
const res = await client.messages.create({
  model: 'claude-sonnet-5',
  max_tokens: 2048,
  messages: [
    {
      role: 'user',
      content: [
        {
          type: 'document', // 材料在前
          source: { type: 'base64', media_type: 'application/pdf', data: pdf },
          title: 'report.pdf',
          citations: { enabled: true }, // 打开引用
        },
        { type: 'text', text: '用五句话总结这份报告的核心结论,每句都要有依据。' }, // 指令在后
      ],
    },
  ],
})
 
// 开了 citations 后文本会被切成多个 text 块,每块可能带 citations
for (const block of res.content) {
  if (block.type !== 'text') continue
  const pages = (block.citations ?? [])
    .filter((c) => c.type === 'page_location')
    .map((c) => `p.${c.start_page_number}`) // end_page_number 不包含,单页引用直接看 start
  process.stdout.write(block.text + (pages.length ? ` [${pages.join(', ')}]` : ''))
}

一个限制昨天提过:citations 和结构化输出不能同时开。所以「带页码的摘要」和「填一张 JSON 表」是两条不同的路——需要引用就用 citations 拿文本块自己拼,需要严格结构就放弃 API 级引用、在 schema 里留一个 page 字段让模型自己填(可靠性会差一档)。

prompt caching:把不变的前缀存起来,反复提问只付零头

回到「每次都重新翻整箱材料」的问题。搭档翻第一遍的时候如果顺手做了索引,第二次、第三次再问就不用从头读了。提示缓存(prompt caching)就是这份索引:你在请求里标出一个位置说「到这里为止的内容请缓存」,服务端会把这段前缀处理后的状态存下来,接下来几分钟内再发一模一样的前缀,就直接复用,只付零头。

关键词是前缀。缓存匹配的是「从请求开头到 cache_control 标记为止的每一个字节」,顺序是工具定义、system、messages。任何一个字节变了,后面的缓存全部失效。这就解释了昨天说的「名片上不要写日期」:system 开头放一个每次都变的时间戳,前缀永远对不上,缓存一次都命中不了。正确的摆法是:不变的放前面(system、文档),会变的放后面(这一轮的问题),缓存标记打在不变部分的末尾。

价格上,写入缓存的那一次是正常输入价的 1.25 倍,之后每次命中只收 0.1 倍。所以它不是白送的:一份文档你只问一个问题,开缓存反而多花 25%;问三次以上才开始净省。缓存默认存 5 分钟,每次命中会刷新;还有一个 1 小时档,写入价 2 倍,适合间隔更长的场景。另外有一个最小长度门槛(当前主力模型是 1024 token,Haiku 4.5 是 4096),前缀太短不会缓存也不会报错——这是「明明开了缓存却没命中」最常见的原因之一。

把缓存标记打在 document 块上:

cached.ts
import fs from 'node:fs'
import Anthropic from '@anthropic-ai/sdk'
 
const client = new Anthropic()
const pdf = fs.readFileSync('report.pdf').toString('base64')
 
async function ask(question: string) {
  const res = await client.messages.create({
    model: 'claude-sonnet-5',
    max_tokens: 1024,
    messages: [
      {
        role: 'user',
        content: [
          {
            type: 'document',
            source: { type: 'base64', media_type: 'application/pdf', data: pdf },
            cache_control: { type: 'ephemeral' }, // 缓存到这里为止:文档不变,问题在后面变
          },
          { type: 'text', text: question },
        ],
      },
    ],
  })
  const u = res.usage
  // 第一次:cache_creation 大、cache_read 为 0;第二次起:cache_read 大、cache_creation 为 0
  console.log(
    `write=${u.cache_creation_input_tokens} read=${u.cache_read_input_tokens} uncached=${u.input_tokens}`
  )
}
 
await ask('这份报告的核心结论是什么?')
await ask('报告里提到的风险有哪些?') // 同一前缀,命中缓存

用 usage 字段验证:缓存到底命中了没有

前面讲了「应该省」,怎么知道「真的省了」?答案回到第一节的 usage。开了缓存之后它多出两个字段:cache_creation_input_tokens 是这次写进缓存的 token 数,cache_read_input_tokens 是这次从缓存读出来的 token 数;原来的 input_tokens 变成「没被缓存覆盖的那部分」。三者相加才是这次请求真正处理的输入总量。

一次健康的「先写后读」应该是这样:第一次请求 cache_creation 约等于文档大小、cache_read 为 0;第二次请求 cache_read 约等于文档大小、cache_creation 为 0、input_tokens 只剩问题那几十个 token。如果第二次 cache_read 还是 0,逐项排查:前缀里有没有会变的东西(时间戳、随机 id、没排序的 JSON);两次请求的模型是不是同一个;前缀有没有过最小长度门槛;两次间隔有没有超过 5 分钟;工具列表的顺序是不是一致。这五条覆盖了九成的「缓存失效」。

把这套验证写进代码是个好习惯:在日志里打出这三个字段,做一个「缓存命中率」的指标,上线后看一眼就知道前缀设计有没有被谁改坏。这是今天实验的最后一步,也是你以后接任何长文档需求时的第一道自检。

源码导读

动手实验

🧪 D2 实验:读一份 PDF 并输出带页码引用摘要的脚本(TS 与 Python 各一)

代码位置:labs/claude-mastery/day-02-pdf-cited-summary

验收标准:

  1. MOCK=1 pnpm start sample.pdf 离线打印出一段摘要,每句话后面带形如 [p.3] 的页码标记。
  2. 有真实密钥时 pnpm start sample.pdf 输出的每个页码都能在 PDF 里翻到对应原文。
  3. 连续问两次,第二次打印的 cache_read_input_tokens 明显大于 0,cache_creation_input_tokens 为 0。
  4. max_tokens 改小到摘要被截断,程序会明确打印 stop_reason=max_tokens 的警告而不是静默输出半截。
  5. 文档超过页数上限或不是 PDF 时,程序给出可读的错误提示。

动手之前先确认两件事:你有一份不加密、几页到几十页的 PDF(没有就用 lab 目录里自带的 sample.pdf);密钥放在 .envANTHROPIC_API_KEY,没有密钥就全程用 MOCK=1。卡住了先看 README 的「常见坑」。

  1. 在 starter 目录 pnpm install 后跑 MOCK=1 pnpm start sample.pdf,看到一段带 [p.N] 标记的模拟摘要,确认骨架能跑。
  2. 完成练习 1:把 PDF 读成 base64 装进 document 块,开启 citations,document 块放在 text 块之前。
  3. 完成练习 2:遍历返回的内容块,把每条 page_location 的起始页码拼到对应句子后面,并在末尾打印 stop_reason。
  4. 完成练习 3:给 document 块加 cache_control,连问两个问题,打印三个缓存相关的 usage 字段并对比。
  5. 把摘要的问题换成三个不同的问题再跑,观察 cache_read 是否稳定命中;如果没有,按正文的五条清单排查。

面试题

今天 3 道题在下方题库区,侧重上下文窗口为什么是最稀缺资源、prompt caching 的前缀语义、citations 与自己让模型抄原文的区别。展开后先看「分析过程」再看要点——照着推导练,比背要点管用。标注「国内高频 / 海外高频」方便按目标市场取舍。

检查清单与明日预告

  • 能用官方 SDK 发出一次 Messages API 调用,并读懂返回里的内容块与 usage
  • 能把 PDF 或图片作为内容块传给 Claude,并开启 citations 拿到带页码的引用
  • 能说清 prompt caching 省在哪、什么情况下反而不省,并从 usage 字段验证缓存是否命中
  • 能背出「缓存不命中」的五条排查清单
  • 实验的 5 条验收标准全部通过
  • 3 道面试题不看要点也能答出至少 2 道

明天(D3)我们离开 API,进入 Claude Code——一个会自己读文件、跑命令、改代码的搭档。你会装好它,给自己的项目写第一份 CLAUDE.md,用 Plan Mode 走完「先探索、再计划、再动手」,并且第一次把示例任务「给 TODO API 补校验和测试」真的交给它做。今天讲的「上下文是最稀缺的资源」在那里会变成每天都要面对的现实:Claude Code 读的每个文件、跑的每条命令都在消耗同一个窗口,所以 D3 有一半篇幅在讲怎么管它。

面试题库

  • 为什么说上下文窗口是 LLM 应用里最稀缺的资源?窗口已经有一百万 token 了,这个说法还成立吗?Why is the context window called the scarcest resource in LLM applications? With million-token windows, does that still hold?
    国内高频海外高频基础#context-window#cost

    分析过程 · 先想清楚再作答

    1. 题眼在第二句。只答「窗口有上限」已经过时了,面试官想听的是「窗口变大之后为什么还稀缺」。
    2. 从三条因果链推:一、模型无状态,每次请求都把全部输入重读一遍,输入 token 按次计费——窗口大只解决了放得下,没解决每次都要重搬;二、上下文越长,延迟越高、注意力越稀释,模型对早期指令的遵守度会下降,也就是「性能随填充度下降」;三、Agent 场景里每读一个文件、每跑一条命令的输出都进同一个窗口,填得比聊天快得多。
    3. 结论:窗口大了,稀缺性从「放不下」变成了「每一 token 都在花钱和稀释注意力」,所以管理手段变成了主动管:只放必要的、把不变的缓存起来、把查资料的活派给独立上下文的子代理、该清就清。
    4. 生产视角:算一笔账——60 页 PDF 约 10 万 token,围着它问 10 个问题就是 100 万输入 token;不用缓存和不用缓存的差价是一个量级。
    5. 可预期的追问:那什么时候应该让上下文积累?在一个复杂问题里深挖时历史是有价值的;判据是「这段历史下一步还会不会用到」。

    How to reason about it · think before answering

    1. The second sentence is the point. 'The window has a limit' is a dated answer; explain why scarcity survives large windows.
    2. Three causal chains: models are stateless so every request re-reads the whole input and bills it, a big window only solves fitting, not re-sending; longer context means more latency and diluted attention, so adherence to early instructions degrades as the window fills; and in agent workflows every file read and command output lands in the same window, filling it far faster than chat does.
    3. Conclusion: scarcity shifted from 'won't fit' to 'every token costs money and attention', so the discipline becomes active management — include only what is needed, cache the stable prefix, delegate research to subagents with their own context, and clear between tasks.
    4. Production math: a 60-page PDF is roughly 100k tokens; ten questions about it are a million input tokens; caching versus not caching is an order of magnitude apart.
    5. Follow-up: when should context accumulate? While deep in one complex problem where the history is still load-bearing; the test is whether the next step will use it.

    答题要点

    • 模型无状态,每次请求重读全部输入并计费;窗口大只解决放得下,不解决每次重搬
    • 上下文越长延迟越高、注意力越稀释,早期指令遵守度下降
    • Agent 场景每次读文件、跑命令的输出都进窗口,填得比聊天快得多
    • 对策:只放必要的、缓存不变前缀、用子代理隔离查资料、任务之间清空

    Key points

    • Models are stateless: every request re-reads and bills the full input; a large window solves fitting, not re-sending
    • Longer context raises latency and dilutes attention; adherence to early instructions drops
    • Agent workflows dump every file read and command output into the same window
    • Tactics: include only what's needed, cache the stable prefix, isolate research in subagents, clear between tasks
  • prompt caching 省在哪?什么情况下反而不省?线上发现缓存命中率是零,你怎么排查?Where does prompt caching save money, when does it cost more, and how do you debug a zero cache-hit rate in production?
    国内高频海外高频进阶#prompt-caching#cost

    分析过程 · 先想清楚再作答

    1. 三问对应三层:原理、边界、排查。只答第一层是背文档,第三层才体现有没有真的上过线。
    2. 原理一句话:缓存匹配的是请求开头到 cache_control 标记为止的精确前缀(顺序是工具、system、messages),命中时这段只收正常输入价的 0.1 倍;代价是写入那一次收 1.25 倍(1 小时档 2 倍)。
    3. 不省的情况由此推出:同一前缀只用一次(多付 25%);前缀里有每次都变的内容(时间戳、随机 id、未排序 JSON、用户名),导致每次都在写永远用不上的缓存;前缀短于最小门槛(主力模型 1024 token,Haiku 4.5 是 4096)根本不会缓存;两次请求间隔超过 TTL。
    4. 排查清单按发生概率排:一看 system 或工具定义开头有没有动态内容;二看两次请求的模型 id 是否一致;三看前缀长度是否过门槛;四看间隔是否超 5 分钟;五看工具列表顺序是否稳定。判据只有一个字段:usage.cache_read_input_tokens 是否大于 0。
    5. 可预期的追问:断点应该打在哪?不变的末尾——工具定义末尾、system 末尾、长文档末尾、多轮对话倒数第二条消息,最多四个;打在每轮都变的内容上等于白写。

    How to reason about it · think before answering

    1. Three questions, three layers: mechanism, boundaries, debugging. The third layer is what shows production experience.
    2. Mechanism: the cache matches the exact byte prefix from the start of the request to the cache_control marker (tools, then system, then messages). A hit bills that prefix at 0.1x input price; the write costs 1.25x (2x for the one-hour TTL).
    3. When it costs more: a prefix used only once (+25%); volatile content inside the prefix — timestamps, random ids, unsorted JSON, user names — so every call writes a cache nothing will read; a prefix below the minimum (1024 tokens on current flagship models, 4096 on Haiku 4.5) that silently never caches; requests spaced beyond the TTL.
    4. Debug order by likelihood: dynamic content at the head of system or tool definitions; model id mismatch between calls; prefix under the minimum; gap over five minutes; unstable tool ordering. The single signal is usage.cache_read_input_tokens greater than zero.
    5. Follow-up: where do breakpoints go? At the end of stable sections — tools, system, the long document, the second-to-last message in a multi-turn chat — at most four; a breakpoint on per-turn content is a wasted write.

    答题要点

    • 匹配精确前缀(工具 → system → messages 到标记为止);命中 0.1 倍,写入 1.25 倍
    • 不省:前缀只用一次、前缀含动态内容、前缀短于最小门槛、间隔超过 TTL
    • 排查:动态内容、模型不一致、长度不够、间隔太久、工具顺序变了;看 cache_read_input_tokens
    • 断点打在不变部分的末尾,最多四个

    Key points

    • Matches the exact prefix (tools → system → messages up to the marker); hits bill 0.1x, writes 1.25x
    • Costs more when the prefix is used once, contains volatile content, is under the minimum length, or requests exceed the TTL
    • Debug: dynamic content, model mismatch, length, gap, tool ordering; verify via cache_read_input_tokens
    • Place breakpoints at the end of stable sections, at most four
  • citations 和在提示词里要求模型「引用原文并注明页码」有什么本质区别?什么场景下不能用 citations?How do API citations fundamentally differ from prompting the model to quote sources with page numbers, and when can't you use them?
    国内高频海外高频进阶#citations#grounding

    分析过程 · 先想清楚再作答

    1. 这题考的是「可信度从哪来」。答成「citations 更方便」是表面;本质区别是谁来保证引用的真实性。
    2. 拆法:提示词方案里,引用和页码都是模型生成的自由文本——它可能顺手改写原文、可能记错页码,你无法区分「真引用」和「自以为引用」。citations 方案里,模型内部以标准格式输出引用意图,API 在服务端解析并核对,返回的 cited_text 一定是文档里真实存在的段落,page_location 的页码由 API 给出。真实性由 API 保证而不是由模型自觉保证。
    3. 附带的两点好处:cited_text 不计入输出 token,比让模型抄原文便宜;返回是结构化的内容块,程序可以直接高亮、跳转,不用正则去猜「第 3 页」出现在哪。
    4. 不能用的场景:与结构化输出(JSON Schema)不兼容,二者同开会报 400;此时要么放弃 API 级引用、在 schema 里留 page 字段让模型自己填(可靠性差一档),要么分两步:先 citations 拿事实,再用结构化输出整理。
    5. 可预期的追问:页码字段的语义?start_page_number 从 1 开始,end_page_number 不包含;多文档时 document_index 区分来源。再追问「能否验证引用质量」——能,用 cited_text 与原文做字符串比对,或抽样人工核对。

    How to reason about it · think before answering

    1. The question is about where trust comes from. 'Citations are more convenient' is surface; the real difference is who guarantees the quote is real.
    2. With prompting, both the quote and the page number are free text the model generates — it may paraphrase, it may misremember the page, and you cannot tell a real quote from an imagined one. With citations, the model emits citation intent in a standard format, the API parses and verifies it server-side, cited_text is guaranteed to exist in the document, and page_location comes from the API. Fidelity is enforced by the API rather than promised by the model.
    3. Two side benefits: cited_text does not count toward output tokens, so it is cheaper than asking the model to copy; and the result is structured content blocks your UI can highlight and jump to without regex guessing.
    4. When you can't: citations are incompatible with structured outputs (JSON Schema) — enabling both returns a 400. Either drop API-level citations and add a page field to the schema (one notch less reliable), or split into two calls: citations for facts, structured output for shaping.
    5. Follow-ups: page semantics — start_page_number is 1-indexed and end_page_number is exclusive; document_index distinguishes sources. Can you audit citation quality? Yes — string-match cited_text against the source, or sample manually.

    答题要点

    • 提示词引用是模型生成的自由文本,可能改写、记错页码,无法区分真假
    • citations 由 API 在服务端解析核对,cited_text 一定存在于文档中,页码由 API 给出
    • cited_text 不计输出 token,返回结构化便于高亮跳转
    • 与结构化输出互斥;需要两者时分两步或在 schema 留 page 字段

    Key points

    • Prompted quotes are free text the model generates — it may paraphrase or misplace pages, and you can't tell
    • Citations are parsed and verified server-side; cited_text is guaranteed to exist and page numbers come from the API
    • cited_text is free of output-token cost and the structured blocks enable highlighting and navigation
    • Mutually exclusive with structured outputs; split into two calls or add a page field to the schema

评论

登录后即可参与讨论

还没有评论,来说第一句。