補寫於 2026 年 9 月 4 日,日期掛回文章原本該發的那天。文中的版本號與「目前」指的是撰寫當日的官方文件狀態。

模型回答的最後一行寫著「來源:使用手冊第 12 頁」。你翻到第 12 頁,那句話不在上面。這個 12 是怎麼算出來的?

答案通常很掃興:它沒算。那個頁碼跟正文是同一批 token,一起被生成出來的,所以它跟旁邊那句話一樣,是根據上下文挑一個看起來最合理的數字填進去。prompt 寫得再嚴格也改不掉這件事,「請務必附上原文出處與頁碼」只會讓它挑得更認真,挑出來的仍然是猜的。引用在這個做法裡只是一種文字風格,沒有任何機制保證它指得到東西。

Claude 的 Messages API 有個開關會把這條產生路徑整個換掉。開關本身只有一行 citations: {"enabled": true},但它背後動了三層東西:引文由誰產生、文件在哪一層被切開、座標由誰計算。這三層決定了你拿到的東西有多可靠,也決定了你的前端要重寫多少。所以這篇從底下往上拆,拆完再回頭講怎麼開。

第一層:那段引文是誰產生的

開了 citations 之後,回應裡每一個 citation 物件都有一個 cited_text 欄位,裡面裝的是原文的那段字。關鍵在於這段字的來路:它由 API 從你送進去的文件裡抽出來,然後放進回應,不經過生成。

差別用默寫跟影印來想最快。模型自己寫引文是默寫:它讀過那份文件,記得大意,記得句子的形狀,於是寫出一段非常像原文的話,八成的字是對的,剩下兩成是它替原文補的。影印機不需要記得那頁寫什麼,只要知道印的是第幾頁,出來的就是原件。cited_text 走的是影印那條路。

官方文件把這件事寫成一句承諾:citations 回傳的引用「guaranteed to contain valid pointers to the provided documents」,因為 API 會把引用解析成標準格式並直接抽出原文。指標一定指得到你給的文件裡真實存在的位置。頁碼會不會指到不相干的段落是另一回事,但它不會指到一個不存在的地方。

計費那邊也跟著換了。文件在 token costs 那段列了三件事:開 citations 會讓 input token 略增,因為系統提示要加料、文件要切塊;cited_text 不計入 output token;這段文字在後續輪次被傳回去的時候,也不計入 input token。官方拿這點去跟 prompt 做法比,用的字眼是「可能省錢」,沒有給任何數字。同一段還有一句更含糊的宣稱:在 Anthropic 自己的評測裡,citations 比純 prompt 做法「significantly more likely to cite the most relevant quotes」,沒附方法也沒附數字,所以它的身分是廠商自述。前面那條「指標必然有效」不同,那是機制決定的,不用靠評測背書。

第二層:切塊發生在你看不到的地方

一個指標指多長?

引用不是任意長度的。文件進到 API 之後會先被切成塊,模型只能整塊整塊地引用,不能引半塊。所以「引用的最小單位」不是模型決定的,是切塊決定的,而切塊發生在你的請求送達之後、模型讀到文件之前那一層。

這件事像事先在紙上壓好的撕線。你可以沿著線撕下任何一段,但撕不出線以外的形狀。線畫在哪,決定了你能拿到多細的引用。

而線畫在哪,取決於你用哪一種 document。官方文件在 choosing a document type 那節的第一句是「Three document types are supported for citations.」,就三種,附一張表:

型別 適合 切塊方式 引用格式
Plain text 一般文字文件、散文 句子 字元索引(0-indexed)
PDF 有文字內容的 PDF 句子 頁碼(1-indexed)
Custom content 清單、逐字稿、特殊格式、需要更細的引用 不做額外切塊 區塊索引(0-indexed)

前兩種的撕線是 API 幫你畫的,畫在句號上;PDF 會先把文字抽出來,再對抽出來的文字切句子。

第三種是把剪刀交還給你。custom content 不做額外切塊,你送進去的每一個 content block 就是一個最小可引用單位。想讓引用停在一整段對話的發言邊界,就把每則發言包成一個 block;想讓它停在表格的一列,就一列一個 block。

官方直接給了選型判準:如果你希望模型能引用 RAG chunk 裡的特定句子,就把每個 chunk 放成一份 plain text document;如果不想再被切,或想自己控制切法,就把 chunk 放進 custom content。

還有一條容易忽略的邊界:只有 source 裡的文字可以被引用。titlecontext 會餵給模型看,但不會出現在任何引用裡,而 title 有長度限制,所以中繼資料建議塞 context,字串或 stringified JSON 都收。你想讓模型「知道」但不希望它拿去當引文的東西,放這裡。

第三層:座標由誰計算,以及為什麼有兩套數法

塊切好了,每一塊得有個地址,不然回應沒辦法告訴你它引的是哪一塊。這一層最值得慢慢讀,它決定了你的渲染程式碼會不會在上線第三週的某個週五晚上突然對不上位置。

citation 物件有四種型別,一種對應一種地址寫法。純文字文件回 char_location,PDF 回 page_location,custom content 回 content_block_location,而搜尋結果回 search_result_location。前三種的形狀很像,都帶 document_indexdocument_titlecited_text,差別只在那組起訖欄位叫什麼名字。

然後你會撞到第一個怪東西:這四種地址不是同一種數法。

char_locationstart_char_index 從 0 起算,end_char_index 是 exclusive,也就是不含尾。content_block_location 一樣,0 起算、尾不含。但 page_locationstart_page_number 是 1-indexed,因為那是給人看的頁碼,人翻書從第 1 頁開始翻。它的 end_page_number 卻仍然是 exclusive。

把這兩條規則疊起來,你會得到一個很容易寫錯的組合:

1
2
3
4
5
6
7
8
{
"type": "page_location",
"cited_text": "The exact text being cited",
"document_index": 0,
"document_title": "Document Title",
"start_page_number": 1,
"end_page_number": 2
}

(照抄官方文件的欄位範例,我沒有實際打過這個 API,下同。出處:https://platform.claude.com/docs/en/build-with-claude/citations

這個東西涵蓋的只有第 1 頁。你的介面如果直接把兩個數字串成「第 1 至 2 頁」印給使用者看,它每一次都會多印一頁,而且因為引文本身是對的,沒有人會發現那個頁碼多算了。錯得很安靜的 bug 都長這樣。

第二個怪東西在 document_index。它是 0 起算,但它數的範圍比你想的大:官方的說法是從請求裡所有 document content block 的清單去算,跨越所有 message。多輪對話裡你前面幾輪送過的文件也在同一條清單上,所以 document_index: 3 數的是整個請求裡的第 4 份文件,跟它出現在哪一則訊息無關。你如果按 message 分開維護文件陣列,這裡就會錯位。

search_result_location 的麻煩不在數法,在欄位。它來自另一個功能頁(search results),欄位跟前面三種不是同一套:它沒有 document_index,也沒有 document_title,改成 search_result_indexsourcetitle,起訖用的是 start_block_indexend_block_indexsearch_result_index 也是 0 起算,一樣跨所有 message 與 tool result 依出現順序數。

所以那個把 citation 轉成「文件標題 + 位置」標籤的 function,只認得 document_indexdocument_title 的話,遇到搜尋結果就得走另一條分支。不難寫,但它是那種你不知道就不會去寫的分支。

三層拆完,回頭看那個「第 12 頁」就清楚了。以前它會錯,是因為它跟正文共用同一個產生器;現在它不共用了,它是切塊時被記下來、抽取時原樣帶回來的一個數字。同一個數字,兩種身世。

三層機制長出來的第一個副作用:回應被切碎了

citations 一開,回應就不再是一整塊文字。同一段話會被拆成好幾個 text block,純敘述的那幾塊沒有 citations 欄位,有依據的那幾塊各自帶一個 citations 陣列。官方的描述是回應現在可能包含多個 text block,每個 block 裝一項主張,加上支持這項主張的引用清單。實際長這樣:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
{
"content": [
{ "type": "text", "text": "According to the document, " },
{
"type": "text",
"text": "the grass is green",
"citations": [
{
"type": "char_location",
"cited_text": "The grass is green.",
"document_index": 0,
"document_title": "Example Document",
"start_char_index": 0,
"end_char_index": 20
}
]
},
{ "type": "text", "text": " and " },
{
"type": "text",
"text": "the sky is blue",
"citations": [ /* ... */ ]
}
]
}

一句「According to the document, the grass is green and the sky is blue.」變成四塊。那個 and 自己就是一塊。

如果你的前端本來是「把 content 裡所有 text 接起來丟進 markdown renderer」,接起來還是原本那句話,畫面看起來沒壞,但這個功能等於白開了。引用的邊界正好就在切開的地方,那才是你要拿來畫底線、掛 tooltip、做成可點擊來源的資訊。要用它就得遍歷 content 陣列,帶 citations 的那幾塊另外包一層。

串流還有一層節奏要注意。citations 走 content_block_delta 事件裡的 citations_delta,每個 delta 帶一個 citation,你要把它 append 到當前 text block 的 citations 陣列。同一塊文字的引用是陸續到的,收到第一個就渲染完畢的寫法會漏掉後面的。

開關寫在哪一層

機制拆到這裡,開關本身反而沒什麼好講的。

citations 是 document content block 自己的一個欄位,跟 sourcetitlecontext 同一層。它不在 message 層,也不是 top-level 參數:

1
2
3
4
5
6
7
{
"type": "document",
"source": { "type": "text", "media_type": "text/plain", "data": "Plain text content..." },
"title": "Document Title",
"context": "Context about the document that will not be cited from",
"citations": { "enabled": true }
}

有一條規則會咬人:同一個請求裡的文件必須全開或全不開,不能只給其中一份開引用。官方原文是「citations must be enabled on all or none of the documents within a request」,前面加了 currently,代表這是目前的限制。

剩下都是不用你操心的事:GA 功能、不需要任何 beta header(官方 curl 範例只帶 content-typex-api-keyanthropic-version 三個 header)、沒有獨立端點(它是 Messages API 的一個功能,照你原本打 /v1/messages 的那條路走)、模型支援度官方原句是 all active models support citations,token counting 跟 batch processing 也都相容。搜尋結果那條另有例外,Claude Haiku 3 不支援。

官方說文件可以直接放在 message 裡,用 base64、純文字或 URL,也可以先上傳到 Files API 再用 file_id 引用。這裡我要停一下:官方那頁沒有給出 source.type 的完整清單,只有散在各個範例裡的用法,所以我不列舉有哪幾種,需要精確清單請自己去對 Messages API 的 schema。

跟 prompt caching 一起用是可以的,但位置有講究:citation block 本身不能被快取,能快取的是來源文件,cache_control 要加在頂層的 document content block 上。

有一條路是明文走不通的

想到出處,很多人第一個念頭是叫模型吐一包乾淨的 JSON:{claim, quote, page} 三個欄位,schema 綁死,解析端就穩了。structured outputs 正好是為這種需求做的。

這條路在這裡是死的。

官方 Warning 的原文:只要你在任何使用者提供的文件上開了 citations(document block 或 search_result block),同時又帶了 output_config.format 參數(或已經 deprecated 的 output_format),API 直接回 400。理由是 citations 需要把引用區塊跟文字交錯輸出,跟嚴格 JSON schema 的約束打架。

順帶一提,這篇是讀官方文件寫出來的,我沒有實際打過這個 API。上面所有欄位名、規則跟數字都照抄文件,沒有任何一句來自我自己的執行結果。

取捨得先想清楚:你要的是「機器能安全解析的結構」,還是「人能點回原文的位置」。目前同一個請求裡只能選一個。兩個都要的話,就得自己把回應的 content 陣列組成你要的結構,等於把 structured outputs 那層補回來。

另外兩條邊界順便記著。掃描出來的 PDF 沒有可抽取的文字,引用不到,因為目前只支援文字引用。.docx.xlsx 這類格式 document block 本來就不收,官方要你先自己轉成純文字。

什麼情況你可以整套忽略

先講立場:出處這件事該由資料結構承擔,不該由 prompt 承擔。prompt 能做的事是影響機率,資料結構決定的是可能跟不可能。你在系統提示裡寫十行「務必附上正確頁碼」,換來的是頁碼更常對;把引用做進 content block 的欄位裡,換來的是頁碼指不到不存在的地方。等級差很多。

會讓我收回這段話的條件有三個,任一成立就別急著改:

你的文件短到整份塞得進 prompt,而且使用者眼前就有全文。這時候「出處」只是排版上的方便,讀的人一眼掃得到,多接一層機制換不到什麼。

非要嚴格 JSON 不可,因為接手回應的是另一支程式。那就是上一節那個 400,二選一。

那些引用從來沒有人點過。這種功能上線後很容易變成裝飾,沒量過點擊的話,你花的力氣是在讓畫面看起來比較可信,那是另一件事。

同一個形狀,在別的系統裡

把 citations 這件事抽象一層,它處理的是一個更普遍的問題:當一個系統的輸出必須指得回它的輸入,那個指標應該由誰計算?

編譯器早就給過答案。你的 TypeScript 被壓成一坨看不懂的 JavaScript,瀏覽器報錯卻能告訴你是原始碼第 47 行,靠的是 source map,一份在轉換當下就記下來的對照表。沒有人叫 minifier「請盡量記得原本的行號」,那個對照表是轉換過程的副產品,跟輸出一起被算出來。

資料庫的外鍵是同一個形狀。你可以在應用層自己把 id 拼進另一張表,多數時候也會對,但對不對取決於寫程式的人有沒有出錯;換成外鍵約束之後,指不到的那筆根本寫不進去。

規律相當一致:指標如果由產生內容的那一方順手寫出來,它的可信度不會高過內容本身。要讓指標比內容更可信,就得換一個人算,而且那個人算的時候手上要有原件。

所以回到那張壓了撕線的紙。你能拿到多細的出處,在你決定怎麼把文件送進去的那一刻就定案了,模型回答的時候已經沒得選。下次設計 RAG 的 chunk 切法時,除了問「這樣切檢索得準不準」,可以順便問一句:這樣切之後,使用者點下去看到的會是哪一段。