你的 AI 助理查到五個網頁,回答也附了連結。隔天你想回看,團隊又想加一顆「分享對話」按鈕:昨天的搜尋摘要能一起留下來嗎?搜尋 API 資料保留的難處,就藏在這個看似方便的小功能。供應商如何處理查詢,與你的應用能否保存、重播或匯出結果,需要分開回答。
這篇寫給第一次替 AI 接搜尋、願意複製一個小程式的新手。先用白話畫清資料流,再做一個預設擋住未審核使用的 Worker,最後用假結果驗即時顯示、過期刪除及逐字稿匯出。沒有程式背景,也能先完成政策清單;程式部分需要已有 Node.js 與 Workers 開發環境。
截至 2026 年 10 月 8 日,Cloudflare® Web Search API 官方文件列為 open beta。本文接續搜尋入口與答案可信度的解析,把問題推進到「這筆資料可以留下多久、流向哪裡」。本次執行的是本機假資料控制測試,沒有呼叫付費搜尋服務或驗證其伺服器留存。
先說結論:搜尋 API 資料保留要守四道門
可以搜到 ≠ 可以存下 ≠ 可以分享。把搜尋 API 當圖書館櫃檯:館員交給你的查找清單、書本正文、你自己寫的研究筆記,各有來源與使用條件。櫃檯不保留你的問題,也不代表你能把它交出的整份清單建立資料庫。
- 來源門:辨認 Query、供應商 Output、原始文件與引用紀錄。
- 用途門:分別核對即時顯示、持久化、建庫與匯出權限。
- 管線門:檢查 Gateway、應用日誌、Cache、對話記憶和備份。
- 驗收門:用假結果證明該擋的會擋、該刪的會刪,再接真實帳號。
① 先畫資料流:四種資料,不用同一張許可證
查詢 Query 是你送出去的問題;搜尋 Output 是供應商回傳的標題、網址、描述與排序。之後若另行開啟原始網站,得到的是另一個來源的文件。引用紀錄則是你記下「哪個主張,對應哪個頁面與位置」。這四份材料可能在同一段對話裡出現,卻不應因為放在同一個 JSON 就套上同一條保存政策。
官方運作說明確認請求經指定 AI Gateway 轉交 Ceramic.ai、Exa 或 Linkup,再統一回傳格式;搜尋會出現在 Gateway 紀錄。這是服務端流程。你在應用裡另外寫下 console.log(result)、把工具回應放進聊天歷史,則是新增的資料流。

先拿一張紙,列出每條箭頭的來源、目的地、owner、保留條件與刪除方式。對話系統特別要加上「工具訊息 → 記憶庫 → 分享頁」這條路;如果搜尋摘要被原封不動塞進逐字稿,即使原始 Cache 沒存,它仍可能在分享頁出現。Agent Harness 的分工能幫你找到真正控制這些箭頭的執行層。
② 把條款轉成設定:provider、方案與合約一起綁
哪一格能開?先建立政策物件,至少包含 provider、plan、contractRef、reviewed、display、persist、export。前三格辨認適用對象,後四格決定能做什麼。未確認就維持 false,而且只由伺服器載入;使用者的請求內容不能自稱「已核准」。
以本次讀到、標示 2026 年 2 月 27 日修改的Ceramic 條款第 7(n)~(p) 節為例:它限制為建立資料庫、資料集、索引或 corpus 而收集 Output,也限制把 Output 獨立轉售或提供下載;即時顯示的例外有整合進應用、授權終端使用者等條件。保留超出合理即時顯示所需的範圍,需有適用 Order Form 的明確許可。
第 2 節也說明附加條款與另簽 MSA 的優先關係。因此,政策不能只寫「Ceramic=不能存」,更不能替「合理必要」發明一個全體適用的分鐘數。本篇將真實使用維持未核准;你取得適用方案及書面條件後,才填入具體用途、期限與合約識別。這套範例未逐一核驗 Exa/Linkup 的適用合約,換 provider 時必須建立另一筆政策。
另行取得原始文件,也不會自動獲得全文保存或重用權。應記下網站授權、存取條件與擷取方式;引用網址和研究筆記是另一組待核對欄位。把來源分開,是為了逐項核對,並非把供應商片段改名成「研究筆記」就放行。
③ 搜尋 API 資料保留:先關 Gateway,再查應用 Log 與 Cache
日誌像監視錄影,Cache(快取)像留在櫃檯的備用影本;兩者都可能留住資料。AI Gateway 日誌文件列明日誌預設開啟,可能包含 prompt 與 response。先選專用 Gateway,進入 Settings → Logs 關閉記錄,再依快取設定文件確認 Cache Responses 關閉。不要只依賴新帳號的預設值。
一般 Gateway 文件提供 cf-aig-collect-log: false、cf-aig-collect-log-payload: false 與 cf-aig-skip-cache: true。但截至本次核對,Web Search 的使用頁沒有逐一示範這些 header 或 binding 欄位在 /ai/websearch/ 的效果;本文不把它們寫成已驗證的 Web Search 控制。先用 Gateway 層設定,並在你的端點、帳號與模式驗證後,才開 GATEWAY_REVIEWED。
應用這端,先移除原始 query、結果、錯誤物件及工具訊息的整包記錄;保留必要的事件代碼、HTTP 狀態、隨機 request ID 與政策版本。連 query 的 hash 也要小心:很短的問題可能被猜回來,不應把 hash 直接等同匿名。追蹤欄位仍需要自己的存取與保留條件。
Workers Logs 文件指出新 Worker 預設啟用 observability,呼叫日誌會含 method/URL,自訂 console.log 也會收集。因此用 POST body 傳 query,避免把問題放進 URL;以下設定明確關閉 Worker observability。Tail、Logpush、第三方 APM、代理伺服器與錯誤回報仍須各自查,這個開關不是全系統清除按鈕。
④ 最小 Worker:讓未審核請求在呼叫前被擋住
先在獨立資料夾建立 worker.mjs 與 wrangler.jsonc。官方使用指南要求帳號、Gateway,以及額度或供應商金鑰;Workers 可用 AI binding,websearch() 回傳標準 Response。以下固定 provider 和結果數,把搜尋放在權限檢查之後。
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "search-retention-lab",
"main": "worker.mjs",
"compatibility_date": "2026-10-08",
"ai": {"binding": "AI"},
"observability": {"enabled": false},
"vars": {"GATEWAY_ID": "retention-lab", "GATEWAY_REVIEWED": "false"}
}
把下方存成 worker.mjs。預設政策全部關閉,所以第一次請求得到 policy_blocked 才是正確行為。APP_TOKEN 是保護本範例入口的 secret,不是搜尋供應商金鑰;AI binding 使用帳號/Gateway 的授權。依Workers secret 文件用 npx wrangler secret put APP_TOKEN 設定,勿放在 vars、版本庫或瀏覽器。此命令會立即部署新版本,只在你已準備好的測試專案執行。
export const policy = Object.freeze({
provider: "ceramic", plan: "unreviewed",
contractRef: "", reviewed: false,
display: false, persist: false, export: false,
});
const escape = (s) => String(s).replace(/[&<>"']/g,
(c) => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
const reply = (body, status = 200, type = "application/json") =>
new Response(type === "text/html; charset=utf-8" ? body : JSON.stringify(body), {
status, headers: {"Content-Type": type, "Cache-Control": "no-store",
"Content-Security-Policy": "default-src 'none'; base-uri 'none'; frame-ancestors 'none'",
"X-Content-Type-Options": "nosniff", "Referrer-Policy": "no-referrer"},
});
export function makeWorker(p = policy) {
return {
async fetch(request, env) {
if (!env.APP_TOKEN || request.headers.get("Authorization") !== `Bearer ${env.APP_TOKEN}`)
return reply({error: "unauthorized"}, 401);
const path = new URL(request.url).pathname;
if (path === "/export") return reply({error: "export_disabled"}, 403);
if (path !== "/search" || request.method !== "POST") return reply({error: "not_found"}, 404);
// These values are server-controlled; request JSON cannot override them.
if (!p.reviewed || !p.contractRef || !p.plan || p.plan === "unreviewed" || !p.display || p.persist || p.export ||
env.GATEWAY_REVIEWED !== "true") return reply({error: "policy_blocked"}, 403);
try {
// Bound the streamed request before parsing; Content-Length is not trusted.
const reader = request.body?.getReader();
if (!reader) return reply({error: "bad_query"}, 400);
const chunks = []; let size = 0;
while (true) {
const {done, value} = await reader.read(); if (done) break;
size += value.byteLength;
if (size > 8192) { await reader.cancel(); return reply({error: "too_large"}, 413); }
chunks.push(value);
}
const bytes = new Uint8Array(size); let offset = 0;
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; }
const body = JSON.parse(new TextDecoder().decode(bytes));
const q = typeof body.query === "string" ? body.query.trim() : "";
if (!q || q.length > 1024) return reply({error: "bad_query"}, 400);
const upstream = await env.AI.websearch({
gatewayId: env.GATEWAY_ID, query: q, provider: p.provider, limit: 5,
});
if (!upstream.ok) return reply({error: "search_failed"}, 502);
const result = await upstream.json();
if (!Array.isArray(result.items)) return reply({error: "bad_result"}, 502);
const rows = result.items.slice(0, 5).flatMap((item) => {
try {
const url = new URL(item.url);
if (url.protocol !== "https:" && url.protocol !== "http:") return [];
return [`<li><a href="${escape(url.href)}" rel="noopener noreferrer" target="_blank">${escape(item.title || "開啟來源")}</a></li>`];
} catch { return []; }
});
// Immediate, authenticated application view. No raw JSON/export endpoint.
return reply(`<!doctype html><html lang="zh-Hant"><meta charset="utf-8"><title>本次搜尋</title><h1>本次搜尋的候選來源</h1><ul>${rows.join("")}</ul></html>`,
200, "text/html; charset=utf-8");
} catch { return reply({error: "request_failed"}, 502); }
},
};
}
export default makeWorker();
這份程式刻意只回本次整合的 HTML 候選來源,不提供原始結果 JSON 匯出;對每個標題和 URL 先做 escaping,排除 javascript: 等協定。它不寫資料庫、Cache API、KV 或聊天歷史,也不記錄例外內容。no-store 管的是回傳 HTTP 快取指示,不能刪掉已存在的 Gateway 或瀏覽器資料。
正式開放前,還要加上每位使用者的登入與授權、速率/費用上限、端點逾時及前端畫面結束時清除暫存。共用 token 只適合受控練習,不能當多租戶權限系統。瀏覽器看得到的內容仍可能被複製;HTML 顯示本身不證明符合 Ceramic 的整合與不可獨立擷取條件,需先核對具體產品用途,才變更伺服器上的 display 政策。
⑤ 用假結果驗三個情境:顯示、刪除、分享逐字稿
還沒帳號也能開始:假結果由你自己建立,不涉及保存真實供應商 Output。把下方存成 acceptance.mjs,執行 node acceptance.mjs。測試注入的是 env.AI.websearch 的替身,並非 Cloudflare 伺服器。
import assert from "node:assert/strict";
import {makeWorker, policy} from "./worker.mjs";
let calls = 0;
const fake = {items: [{url: "https://example.com/guide", title: "FAKE_RESULT_CANARY"},
{url: "javascript:alert(1)", title: "bad"}]};
const env = {APP_TOKEN: "test-only", GATEWAY_ID: "test", GATEWAY_REVIEWED: "true",
AI: {async websearch(args) {calls++; assert.equal(args.provider, "ceramic"); return Response.json(fake);}}};
const request = (path, body, token = "test-only") => new Request(`https://example.test${path}`, {
method: "POST", headers: {Authorization: `Bearer ${token}`}, body: JSON.stringify(body)});
const approvedFakePolicy = {...policy, reviewed: true, display: true, plan: "synthetic", contractRef: "synthetic-only"};
assert.equal((await makeWorker().fetch(request("/search", {query:"test", reviewed:true}),env)).status,403);
assert.equal(calls,0);
const w=makeWorker(approvedFakePolicy);
assert.equal((await w.fetch(request("/search", {query:"test"}, "wrong"),env)).status,401);
const view=await w.fetch(request("/search", {query:"test"}),env);
assert.equal(view.headers.get("Cache-Control"),"no-store");
const html=await view.text(); assert.match(html,/FAKE_RESULT_CANARY/); assert.doesNotMatch(html,/javascript:/);
assert.equal((await w.fetch(request("/export", {transcript:fake}),env)).status,403);
assert.equal((await w.fetch(request("/search", {query:"test"}),{...env,GATEWAY_REVIEWED:"false"})).status,403);
assert.equal((await w.fetch(request("/search", {query:"x".repeat(9000)}),env)).status,413);
const failed=await w.fetch(request("/search", {query:"test"}),{...env,AI:{async websearch(){throw Error("FAKE_SECRET_CANARY")}}});
assert.equal(failed.status,502); assert.doesNotMatch(await failed.text(),/FAKE_SECRET_CANARY/);
// Synthetic in-memory store only: logical expiry and physical removal are separate.
let now=1000; const store=new Map();
store.set("fake-1",{payload:fake,expiresAt:now+1000});
const read=(id)=>{const x=store.get(id);return x && x.expiresAt>now?x.payload:null;};
assert.ok(read("fake-1")); now=2000; assert.equal(read("fake-1"),null);
const before=store.size; for(const [id,x] of store) if(x.expiresAt<=now)store.delete(id);
assert.equal(store.size,0);
const receipt={case:"synthetic-expiry",scope:"test-map-only",before,after:store.size,deleted:before-store.size};
console.log(JSON.stringify({status:"PASS",checks:8,paid_api_calls:0,deletion_receipt:receipt},null,2));
第一個情境是即時顯示:未核准政策即使收到 reviewed:true 的請求欄位,也必須 403,且搜尋呼叫次數仍為零;只在 synthetic-only 的測試政策下,假標題出現在畫面。第二個情境是過期:時鐘推進後讀取回 null,再實際移除 Map 項目。第三個情境是分享:含原始假結果的逐字稿送到 /export 仍為 403;前端藏按鈕不算通過。
本次在 Node.js 24.15.0 執行此假資料測試通過,付費 API 呼叫為零。刪除收據為 {"scope":"test-map-only","before":1,"after":0,"deleted":1}。範例的一秒只是讓假時鐘跨過到期點,不是任何供應商准許的保存期限;這張收據也只證明測試 Map,沒有替 Gateway、磁碟、備份或瀏覽器完成刪除驗證。
真正接服務時,怎麼交出刪除收據?
若適用合約允許保存,期限至少分成兩件事:到期後停止使用,以及實際刪除內容。TTL(存活時間)像借書到期日;標成不能再借,還不等於已從書架移走。讀取層按 expiresAt 擋住重播,清理作業則刪除每個物件及可回復副本。
每個儲存系統各交一張收據:policy version、opaque record ID、storage scope、到期時刻、刪除工作 ID、結果與驗證時刻。收據不放搜尋片段。然後做三次反查:原 ID 讀回、搜尋索引查找、備份復原路徑。備份若要等輪替,明確記錄期限與復原後再次套用刪除清單的方式;不要先寫「全部已刪除」。
用本機檔案或 KV 補持久化時,還要處理部分失敗、併發和清理作業中斷。上面的 Map 測例是這套流程的縮小版,不能直接搬成正式清理系統。你可以照最小 Harness 實作把停止、重試與驗收分開,再以搜尋 API 品質驗收另測答案;權限通過與答案正確是兩份成績單。
FAQ:搜尋 API 資料保留的八個直接答案
供應商 Zero Data Retention,代表我的 Log 也清空嗎?
不代表。供應商端承諾與 Gateway、應用、模型對話和備份是不同資料流;各自查設定與證據。
Ceramic 公開條款等於所有供應商的政策嗎?
不等於。本文核對的是 Ceramic 指定版本;Exa、Linkup 要對應自己的方案與適用合約。
只存標題、網址,就一定可以永久保留嗎?
先查適用範圍。標題、網址及排序仍可能屬於回傳 Output;縮短資料不會自動解除使用限制。
把 snippet 改寫成摘要,能直接開分享嗎?
不能只靠改寫放行。追查摘要的來源與具體使用權限;逐字稿也要檢查工具訊息及引用片段。
設定 no-store 就算完成保留控制嗎?
還要查其他儲存。它是 HTTP 回應指示;Gateway 日誌、手動儲存及已存在副本需另行控制。
為什麼範例 persist 和 export 都是 false?
因為本篇沒有你的適用保存許可。程式先採即時用途的受控練習路線;若要建庫或分享,必須擴充政策及驗收。
刪除後讀不到,能宣稱實體內容全部清掉嗎?
證據不足。邏輯到期與物件移除分開查;也要查索引、副本和備份。
完全不寫程式,今天可以做什麼?
畫四種資料與所有儲存箭頭。每條填「已確認/待確認」,把待確認的持久化和匯出關閉,再交工程師建立測例。
給新手的三個重點
- 先辨認來源,再決定用途;不要讓「搜尋可用」替「保存可用」蓋章。
- 政策只能由受控伺服器設定,未確認時在呼叫前就擋住。
- 假資料測試驗你自己的控制;真實帳號設定、合約與刪除各有自己的驗收。
接著閱讀
左右滑動查看更多推薦
下一步:交出一張資料流與三份測例
記住:可以搜到、可以存下、可以分享,是三道不同的門。今天先畫出 Query、Output、文件與引用紀錄;把尚未確認的持久化和匯出關閉,再執行假資料測試,留下顯示、到期刪除與逐字稿封鎖紀錄。等每條箭頭都有權限依據與驗收,再接自己的搜尋帳號。更多路線可從AlphaLab AI 專區挑選,或到AlphaLab 課程建立完整 AI 工作流。
