iT邦幫忙

2026 iThome 鐵人賽

DAY 15
0
Build on Google AI

LOCAL:30 天打造 LINE × Google AI 地方服務 Agent系列 第 15 篇

Day 15|對話越來越長之後:Session、摘要與上下文預算

  • 分享至 

  • xImage
  •  

鄉親問完花壇吃什麼,接著聊活動、停車、公車,再問「剛才那個鄉鎮呢?」今天做一個只留最近兩回合、仍能帶回必要線索的上下文管理器。讀者學會控制送出的資料;鄉親少重講地點,已忘記的偏好也不會被舊摘要叫回來。

Day 15 adds a bounded context layer to LOCAL: two complete projected exchanges, a structured topic summary, and separate authoritative backend facts. The offline example measures request bytes and verifies consent-revision invalidation. Real Gemini token counts, reference resolution, and latency are separate integration checks.


一、聊得熱情,服務就要把每句都背起來嗎?

想像地方媽媽帶著長輩到花壇。長輩看著 LINE,先問附近有什麼好吃的,接著聊起八卦山步道、問接駁公車、洗手間與停車收費。聊了幾輪後,長輩突然想起剛才的話題,隨口補了一句:「阿那剛才那個鄉鎮,再找一家吃的。」

這句「剛才那個鄉鎮」,後端要怎麼接?

地方生活服務中,許多長輩習慣語音輸入,句子長、話題會跳,還夾著問候。初次接觸 LLM Agent 的開發者,常直接建聊天陣列,把每輪問答一直 append 進對話工作階段(Session)歷史。

這種「天真全量保留(Naive Full History)」的做法,在正式服務裡會遇到三個問題:

  1. 每一輪都重送前文,成本越滾越大:累積送出的輸入量近似平方成長($O(N^2)$),第三節細算。
  2. 處理時間可能增加:Context 增大通常會增加模型需要處理的輸入量;在手機端,多等幾秒就是多轉幾圈。
  3. 重要資訊可能被稀釋(Lost in the Middle):長上下文研究顯示,上下文位置會影響模型對細節的利用效果。[4] 當對話充斥著無關的公車與停車雜訊時,長輩在第一輪交代的偏好,可能被模型忽略;也可能把隨口提到的「不要爬太陡的坡」,誤當成午餐推薦的條件。

我做彰化地方 LINE 服務時,最在意的是:讓使用者少做一次重複動作,但後端不能替未來的對話背上包袱。 今天接著 Day 14 的店家與經同意偏好,我們要在維持低預算的前提下,接住這句「剛才那個鄉鎮」。

今天做 今天不做
最近兩回合、結構化話題摘要、完整輸入預算 重播全部聊天、讓摘要替人做主同意偏好
偏好更正/忘記後讓舊脈絡原子失效 依賴模型猜測營業時間、步行距離或延遲表現
白名單匯出乾淨容器並安全部署至 Cloud Run 盲目打包整個專案根目錄上傳雲端

二、ADK 保存對話,資料庫保存事實

在進入程式實作之前,先分清楚一件事:Google ADK 的對話工作階段(Session)到底是什麼?它與資料庫有什麼不同?

ADK 是協調模型、工具與事件的編排套件。在 LOCAL 架構裡,我刻意不把 ADK Session 當成偏好、服務單或授權狀態的權威來源。ADK 的 session.state 本可保存動態狀態;但需跨 Session、可撤回、可稽核的地方服務事實,仍交由後端資料庫負責。

一個常見的做法,是讓模型從對話工作階段的聊天紀錄「回憶」偏好。看一個例子:

  • 第一輪長輩說「我吃素」,系統記在對話工作階段紀錄裡。
  • 第四輪長輩點擊「忘記我的飲食偏好」,後端資料庫確實清空了偏好紀錄。
  • 第五輪長輩問「推薦這附近吃的」,如果把前四輪完整對話紀錄送給 Gemini,模型看見第一輪寫著「我吃素」,推論時就會產生衝突——模型到底該聽聊天歷史的『我吃素』,還是後端資料庫的『無偏好』?

這就是為什麼在 Day 14 中,我們刻意讓每回合建立全新、無狀態的 Session,並在 adk_router.py 設定 context.actions.skip_summarization = True,讓工具結果由固定程式呈現,不再送回模型改寫。

Day 15 延續這個分工:

  1. Gemini 負責自然語言理解:透過 before_model_callback 在模型呼叫前組裝受控的短期上下文,讓模型理解「剛才那個鄉鎮」是指「花壇鄉」,並提出 search_local_places(area='花壇鄉', dietary_type='') 的工具呼叫。[1]
  2. 後端資料庫負責權威事實:已同意的素別條件,永遠由後端 TurnTools 在工具真正執行時,從 Firestore / SQLite 原子注入;單號驗證與授權依然走後端資料庫,不交給模型從對話紀錄回想。

ADK 原生具備自動壓縮事件的功能(EventsCompactionConfig),通常由模型對較舊事件進行自動摘要。[2] 但在生產環境中,多叫一次模型寫散文摘要,既增加計費又帶來摘要失真的風險。因此在 Day 15 中,我們採用確定性的結構化話題摘要,僅記錄 goal(如 places/events)與 area(如花壇鄉),不多耗費任何一次模型推論,換來清晰可控的邊界與撤回保證。


三、先帶兩個觀念進來:Token 計量與邊界

在動手管理預算前,有兩個計量觀念必須先講清楚:

觀念一:Token 不是中文字數,全量重送累積成本是平方增長

用字數估 Token 很方便,但不準:繁體中文、標點與 ADK 工具定義的切分方式各有特定編碼。忽略快取、假設每輪新增相近長度對話,若每次都送出全部歷史:

  • 第 1 輪送出:1 輪內容
  • 第 2 輪送出:2 輪內容
  • 第 $N$ 輪送出:$N$ 輪內容
  • 累積送出的 Prompt 總量是 $\sum_{i=1}^N i = \frac{N(N+1)}{2}$,呈現近似平方級的成長曲線($O(N^2)$)。

雖然這不是單次回合的指數爆炸,但在多使用者、高頻率的地方服務中,這份累積輸入量與模型費用仍可能快速增加。

觀念二:輪數限制只是第一道防線,必須檢查完整請求

有些開發者以為「只留最近 3 輪就安全了」。這只對了一半。若某一輪使用者貼上數千字的活動簡章,光是該輪就可能撐爆上下文上限或觸發計費警報。

因此,真正的上下文工程必須雙管齊下:

  1. 第一道:輪數滑動視窗(Sliding Window),將舊回合依序移出。
  2. 第二道:完整請求計量(Full Request Counting)。官方提供的 models.countTokens API 可以接收包含 systemInstruction、完整工具宣告(tools)以及對話本體(contents)的完整請求。[3] 唯有確保整份請求在送出前通過預算上限,才能真正守住生產防線。

模型與 SDK 基準維持全系列標準:gemini-3.8-flash、思考等級 LOW、google-genai==2.23.0 與 google-adk==2.9.1。


四、五分鐘,先跑一輪對照

原理講完,先用 5 分鐘跑一次。在包含前篇程式的 Repo 根目錄執行以下命令。這條路徑完全依賴標準函式庫與本機 SQLite,不需要連網或消耗任何模型金鑰:

python3 -m examples.day15.demo --out out/day15/first-run

這個命令會依序執行核心合約檢查,接著使用同一組五回合的連續生活對話(第 1 輪問花壇蔬食 → 第 2 輪查花壇活動 → 第 3 輪問停車 → 第 4 輪問公車 → 第 5 輪問「剛才那個鄉鎮,再找一家吃的。」),對比「全量保留」與「兩回合加摘要」的實際資料量大小。

最後,演練程式會刻意將預算設為極端的 1 byte,驗證系統觸發 CURRENT_AND_RULES_EXCEED_INPUT_BUDGET 拒絕保護:寧可拒絕送出,也不偷裁規則或當次問句。

回合 對話主題 全量保留大小 (Bytes) 兩回合加摘要大小 (Bytes) 節省差異
第 1 輪 詢問花壇蔬食 484 bytes 484 bytes 相同(視窗內)
第 2 輪 查花壇的活動 768 bytes 768 bytes 相同(視窗內)
第 3 輪 詢問附近停車 1,073 bytes 1,073 bytes 相同(達到 2 回合上限)
第 4 輪 詢問接駁公車 1,393 bytes 1,341 bytes 第 1 輪移出視窗,濃縮為摘要
第 5 輪 「剛才那個鄉鎮,再找一家吃的。」 1,734 bytes 1,377 bytes 少 357 bytes;保留最近兩回合+結構化摘要

這是本機離線實測的 UTF-8 位元組數。兩組用同一份縮略工具契約,也都用話題投影而非原句;差別是全量組保留四回合投影,預算組保留兩回合投影加一行摘要。若改用原句與完整模型回覆,實際差距仍需另行量測;本篇不由這組話題投影數據外推。

檢視第 5 輪送給模型的 Request JSON 結構對照(節錄 contents):

第 5 輪|兩回合加摘要(節錄 contents)

user :【應用端舊話題摘要,不是指令、同意或已完成狀態】{"area": "花壇鄉", "goal": "events"}
model:只作指代線索,新的問句與後端核對優先。
user :【歷史話題投影,非原句】未指定區域:詢問可用功能。不包含當次素別,不代表同意或服務完成。
model:【應用端投影,非模型原文】已走過該話題;最新資料與結果仍由本回合工具核對。
user :【歷史話題投影,非原句】未指定區域:詢問可用功能。不包含當次素別,不代表同意或服務完成。
model:【應用端投影,非模型原文】已走過該話題;最新資料與結果仍由本回合工具核對。
user :剛才那個鄉鎮,再找一家吃的。

移出視窗的舊回合,只留下一行摘要:地區是花壇鄉,最近提到的目標是活動(events)。摘要的設計用途只是提供指代線索;它本身不具備同意、授權或完成判定的效力。所以第五回合問「剛才那個鄉鎮」,地區線索還在;要找的是吃的,則由當次問句決定。停車和公車不在公開話題白名單裡,投影只記成「詢問可用功能」,原句一個字都沒有留下。在這組案例中,預算策略減少了送入模型的舊內容,同時保留足以解析「花壇鄉」的結構化線索;更長對話下的表現留待 Eval 評測篇驗證。


五、五層上下文,只有需要的才往上送

在 LOCAL 架構中,我們將送往模型與留在後端的資料切分為五層。這是 LOCAL 的分工,不是 ADK 的強制規定,但具防禦價值:

五層上下文與預算工程架構設計圖
圖 1:LOCAL 五層上下文預算工程架構圖。前四層組裝為傳入模型的 Request,送出前可走 countTokens 把關整體預算(<= 8,192 tokens,本篇 Demo 量測 bytes);第五層後端真實事實(已同意偏好、單據狀態與版本號閘門)僅在工具執行時由後端注入,不靠聊天歷史回想。

層 內容 是否送給模型 說明
第 1 層:領域規則 systemInstruction+tools 是 納入輸入計量;安全規則與工具宣告不裁切
第 2 層:當次回合 使用者本輪問句 是 問句送入;簽章與 actor 留在後端
第 3 層:最近視窗 最近兩回合話題投影 是 非原句;只存白名單 topic/area
第 4 層:結構摘要 舊話題摘要(goal/area) 是 15 分鐘 TTL;僅作指代線索,非同意事實
第 5 層:後端事實 已同意偏好、單號、版本號 否 工具執行時由後端補入,不靠對話歷史回想

第三層的安全性:什麼是「話題投影(Topic Projection)」?

傳統做法常把原句全文(甚至含隱私發言)整段塞進 Session。但在 LOCAL 中,第三層存的不是原句,而是由 SafeTurn 生成的「公開話題投影」:

  • 長輩即使輸入了個人電話、長輩病況或隱私碎念,經過工具執行後,記錄在視窗裡的只會是公開話題投影,例如「花壇鄉:找店家」。
  • 飲食偏好值、親屬關係、單據 ID 與 Flex 卡片結構完全不混入對話歷史。
  • 當超出兩回合時,移出視窗的內容再濃縮為第四層的 goal 與 area,短期脈絡設定 15 分鐘(900 秒)的滑動有效期。

這項設計也避免了工具回傳被切成兩半。若以訊息則數切分,極易切到只剩 Tool Response 卻丟失原 Function Call,導致語法報錯。以完整的「問答話題投影」裁切,能確保對話結構始終合法。


六、先裁完整回合,再讀最新事實

session_budget.py 第 146~165 行的核心控制流如下。window 包含話題投影與摘要,envelope 是規則及工具契約,measure 則決定本次用 bytes 或真正 Token 計量。

async def prepare(self, window, current, envelope, measure):
    kept = ContextWindow((), window.summary)
    for turn in window.turns:
        kept = kept.append(turn, self.max_turns)
    if getattr(measure, "unit", None) != self.unit:
        raise ValueError("COUNTER_UNIT_MISMATCH")
    counts = []
    while True:
        request = {**envelope, "contents": render_contents(kept, current)}
        count = await measure(request)
        if type(count) is not int or count < 0:
            raise ValueError("INVALID_COUNTER_RESULT")
        counts.append(count)
        if count <= self.limit:
            return BudgetResult(
                request, kept, count, self.unit, tuple(counts),
                len(window.turns) - len(kept.turns),
                bool(not kept.summary.goal and
                     (window.summary.goal or len(window.turns) > len(kept.turns))))
        kept = trim_oldest(kept)

這 20 行做了三件事:

  1. 先留最新視窗:保留最近兩回合(self.max_turns),多出來的話題自動由 summary.absorb 吸收。
  2. 超量逐步降級:若整份請求依然超出預算(count > self.limit),trim_oldest 每次移除最舊的一整回合話題;當回合全數移出後,最後一步才嘗試捨棄摘要。
  3. 守住底線拒絕:如果連當次問題與安全規則都塞不下,trim_oldest 直接拋出 BudgetExceeded 異常終止,不裁規則。

版本跳號防護(Revision Gate):忘記的偏好,怎麼不被舊摘要叫回來?

在 context_store.py 中,短期脈絡 ContextJournal 與 Day 14 的偏好記憶庫共用同一個 preference_revision 版本號碼。

當使用者點擊「忘記我的飲食偏好」時,後端偏好資料庫的版本號碼會從第 1 版跳到第 2 版(Revision 1 → 2)。偏好版本改變後,ContextJournal._read() 會把版本不符的舊 window 視為無效,assert_current() 也會拒絕沿用舊 snapshot;主路由再呼叫 clear() 清理短期脈絡。即使清理沒有完成,下次 read 仍不會取用舊內容。

另外在獨立的撤回演練中,忘記偏好後,下一次 search_local_places 未明示素別時,有效參數會回到 dietary_type='any'。這由後端資料庫保證,而不是寄望模型「請記得遵守撤回規定」。這和上面的第 5 輪指代測試是不同證據層:前者驗證撤回,後者驗證「剛才那個鄉鎮」的上下文解析。

手機端 LINE 連續對話與上文地點辨識實測
圖 2:手機端 LINE 連續對話實測。左圖第一輪輸入「花壇有什麼店推薦吃吃看?」,系統查出花壇和米素食;右圖接續詢問八卦山步道(教學快照查無對應活動),再補充「剛才那個鄉鎮,再找一家吃的」,系統成功再次回傳花壇店家。這段對話只有三回合,花壇仍在兩回合視窗內;摘要接手的情形見第四節離線對照。


七、從哪些證據看出真的做到?

每一層證據各自回答一件事:

證據維度 目前已核對成立的事實 尚未由它證明的事項
本篇核心離線測試 45 項通過:預算計量、整回合修剪、單位不符拒絕、版本號碼不符作廢 Gemini 模型自然語言理解的長期正確率
五分鐘離線 Demo 44 項通過:請求大小由 1,734 降至 1,377 bytes,1 byte 預算拒絕生效 真實線上網路環境的連線延遲
前篇回歸測試 公開 GitHub Actions 的 previous 群組 241 項全數通過;另有 Day 15 flow 14 項通過 不代表真實 Gemini/ADK/Cloud Run 已由這組 CI 驗證
本機 SQLite 觀測 SQLite 記錄明確:同意後有效素別為 vegetarian,忘記後立即變為 any,舊摘要完全清空 正式環境 Firestore 上的相同行為

離線演練中保存了 SQLite 的資料列備份。以下是忘記偏好前後,後端資料庫記錄的真實對比:

// 同意記住後:工具參數補入已同意之偏好
{
  "tool": "search_local_places",
  "proposed_arguments": {"area": "花壇鄉", "dietary_type": "", "keyword": ""},
  "memory_revision": 1,
  "effective_arguments": {"area": "花壇鄉", "dietary_type": "vegetarian", "keyword": ""},
  "preference_origin": "consented_memory"
}

// 執行忘記後:版本跳號(Revision 1 -> 2),短期視窗清空,工具參數回歸 any
{
  "tool": "search_local_places",
  "proposed_arguments": {"area": "花壇鄉", "dietary_type": "", "keyword": ""},
  "memory_revision": 2,
  "effective_arguments": {"area": "花壇鄉", "dietary_type": "any", "keyword": ""},
  "preference_origin": "none"
}

這組對照說明:記憶是否成功撤回,看的是工具有效參數是否從 vegetarian 變成 any,而不是靠餐廳清單有沒有變(因為花壇的素食友善店家在 any 模式下依然可能被查出)。


八、讓下一步有依據,不讓記憶變成炫耀

在同一個 LOCAL 專案中:Day 14 守住使用者同意邊界,Day 15 守住送往模型的預算。

為了避免打包多餘暫存或未公開檔案,在 LOCAL 中我們採用乾淨的「白名單匯出」機制。只打包白名單檔案,本機暫存、.git 與金鑰都不會進入建置內容。在 Git 程式庫根目錄執行:

python3 -m examples.day15.build_context --out out/day15/cloud-context

這條指令會自動盤點 Day 15 所需的最小執行檔,生成附帶 SHA-256 驗證雜湊的 SOURCE_MANIFEST.json,並將容器入口設定為 examples.day15.main:app。將這個乾淨目錄打包後,在 Google Cloud Shell 中僅需兩行指令即可完成安全交付(示例;本篇實際沿用 Day 14 的 Cloud Shell Docker 建置路徑):

# 1. 提交 Cloud Build 建置映像檔
gcloud builds submit --tag asia-east1-docker.pkg.dev/$PROJECT_ID/local-agent-repo/local-day12-agent:day15-v1

# 2. 部署至 Cloud Run 並授權 countTokens 計量(使用 update-env-vars 保留既有設定)
gcloud run deploy local-day12-agent \
  --image asia-east1-docker.pkg.dev/$PROJECT_ID/local-agent-repo/local-day12-agent:day15-v1 \
  --region asia-east1 \
  --update-env-vars LOCAL_APPROVE_COUNT_TOKENS=yes

完成部署後,即可於真實 LINE 頻道驗證短期對話視窗與服務回覆。

最後,特別提醒一個觀念:開發時 Antigravity 選讀 AGENTS.md、架構決策與 Git 變更,是為了解析專案程式碼的靜態脈絡;而服務在 LINE 上運行時,處理的是每位使用者的有限上下文與即時授權。兩者不是同一套記憶,更不能把開發助理的歷史紀錄當成鄉親已經同意的資料。

上下文是預算,不是記憶炫耀。模型可以建議下一步,但不能代替完成判定。

下一篇,我們要把資料換成會蓄意誘導 Agent 的「不可信文件」:就算模型讀到了惡意文字,哪些危險工具依然絕對不能動?那是 Day 16 要守住的另一道防禦界線。


程式與參考資料

完整程式與操作見 examples/day15/README.md;前篇為 Day 14。

[1] ADK:模型呼叫前後的 callbacks
[2] ADK:Context compression
[3] Gemini API:countTokens 與完整請求
[4] Lost in the Middle:長上下文研究


上一篇
Day 14|「今天想吃素」不等於以後都要!經同意的記憶與地方店家查詢
下一篇
Day 16|不可信文件與最小權限
系列文
LOCAL:30 天打造 LINE × Google AI 地方服務 Agent 共 16 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言