LLM 記憶壓縮實戰：滑動視窗 vs 摘要記憶，用答對率與 Token 成本量化取捨
本章節要實作滑動視窗與摘要壓縮兩種記憶，並用事實問答量化「省 token vs 記得住」的取捨 一、滑動視窗記憶 實作只保留最近 N 輪的 SlidingWindowMemory，並驗證在 Context Window 中「忘掉久遠的事」 """SlidingWindowMemory：只保留最近 N 輪對話（FIFO）（W2）。""" class SlidingWindowMemory: def __init__(self, system=None, max_turns=3): """max_turns：保留最近幾輪（1 輪 = 1 user + 1 assistant）。""" self.system = system self.max_turns = max_turns self._messages = [] # 內部保留完整歷史（除錯用） def add_user(self, text): self._messages.append({"role": "user", "content": text}) def add_assistant(self, text): self._messages.append({"role": "assistant", "content": text}) def build_messages(self): """只回傳最近 max_turns 輪 = 最多 2*max_turns 則訊息。""" window = self._messages[-2 * self.max_turns:] # 確保以 user 開頭（避免 assistant 開頭導致 API 報錯） while window and window[0]["role"] != "user": window = window[1:] return window def __len__(self): return len(self._messages) 接著寫一個測試用例 import sys, os sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from memory.llm import chat from memory.window import SlidingWindowMemory SYSTEM = "你是助理，用繁體中文簡短回答。" mem = SlidingWindowMemory(system=SYSTEM, max_turns=2) # 只記最近 2 輪 script = [ "我對甲殼類過敏，請記住。", # 埋在最早——會被滑出去 "我最喜歡的顏色是藍色。", "幫我推薦一部電影。", "再推薦一首歌。", "我對什麼過敏？", # 回頭考：早已滑出視窗 ] for user in script: mem.add_user(user) reply, _ = chat(mem.build_messages(), system=SYSTEM) mem.add_assistant(reply) print(f"你 > {user}\nAI > {reply}\n (視窗內 {len(mem.build_messages())} 則)\n") 執行測試 python demos/w2_window_probe.py 你 > 我對甲殼類過敏，請記住。 AI > 好的，我會記住您對甲殼類過敏。 (視窗內 2 則) 你 > 我最喜歡的顏色是藍色。 AI > 知道了，您最喜歡的顏色是藍色。 (視窗內 4 則) 你 > 幫我推薦一部電影。 AI > 如果您喜歡藍色，或許您會喜歡《藍色是最溫暖的顏色》（Blue Is the Warmest Color），這是一部法國電影，講述了一段深刻的愛情故事。 (視窗內 4 則) 你 > 再推薦一首歌。 AI > 推薦您聽周杰倫的《青花瓷》，這是一首融入中國傳統元素的現代流行音樂，歌詞優美，旋律動聽。 (視窗內 4 則) 你 > 我對什麼過敏？ AI > 要確定您對什麼過敏，需要進行具體的過敏原檢測。這通過皮膚點刺試驗、血液中的IgE測試或其他相關的過敏檢查來進行。如果您有過敏症狀，建議咨詢醫生或過敏科專家並進行專業檢測。 (視窗內 4 則) 可以觀察得到最早的對話内容已經不見了 二、摘要壓縮記憶 實作由滑窗 + 滾動摘要，當—舊對話滑出視窗時，則并入一段由 LLM 維護的摘要。 在下面新增滑動窗口功能 """SummaryMemory：近期用滑動視窗保留原文，滑出的舊對話併入滾動摘要。""" from memory.llm import chat SUMMARIZE_SYSTEM = ( "你是對話摘要器。根據『既有摘要』與『新增對話』，輸出一段更新後的摘要。" "務必保留：使用者的個人資訊、偏好、限制（如過敏/預算）、已達成的結論與待辦。" "用繁體中文，條列或短段落，控制在 150 字內。只輸出摘要本身。" ) class SummaryMemory: def __init__(self, system=None, max_turns=3, summary_model="claude-haiku-4-5"): self.system = system self.max_turns = max_turns self.summary_model = summary_model self._messages = [] # 尚未被摘要的近期訊息 self.summary = "" # 滾動摘要 def add_user(self, text): self._messages.append({"role": "user", "content": text}) def add_assistant(self, text): self._messages.append({"role": "assistant", "content": text}) self._maybe_compress() # 每完成一輪就檢查是否要壓縮 def _maybe_compress(self): """超出視窗的最舊一輪 → 併入摘要。""" keep = 2 * self.max_turns while len(self._messages) > keep: # 取出最舊一輪（user + assistant） overflow = self._messages[:2] self._messages = self._messages[2:] self.summary = self._roll_summary(self.summary, overflow) def _roll_summary(self, prev_summary, overflow_msgs): convo = "\n".join(f"{m['role']}: {m['content']}" for m in overflow_msgs) prompt = ( f"【既有摘要】\n{prev_summary or '（無）'}\n\n" f"【新增對話】\n{convo}\n\n請輸出更新後的摘要。" ) summary, _ = chat( [{"role": "user", "content": prompt}], system=SUMMARIZE_SYSTEM, model=self.summary_model, max_tokens=300, ) return summary.strip() def build_system(self): """把摘要併入 system。""" base = self.system or "" if self.summary: return f"{base}\n\n【先前對話摘要】\n{self.summary}" return base def build_messages(self): return list(self._messages) def __len__(self): return len(self._messages) 修改下前面的 w2_window_probe.py from memory.summary import SummaryMemory reply, _ = chat(mem.build_messages(), system=mem.build_system()) 再執行一次 python demos\w2_window_probe.py 得到結果如下： 你 > 我對甲殼類過敏，請記住。 AI > 好的，已記住您對甲殼類過敏。 (視窗內 2 則) 你 > 我最喜歡的顏色是藍色。 AI > 了解，您喜歡藍色。 (視窗內 4 則) 【摘要更新】 用户对甲壳类过敏，需避免接触。 (視窗內 4 則) 你 > 幫我推薦一部電影。 AI > 好的，根據您喜歡藍色這一喜好，我推薦您觀看《海洋奇緣》（Moana），這是一部充滿藍色海洋風情的動畫電影，故事感人且富有冒險精神。 (視窗內 4 則) 【摘要更新】 用户对甲壳类过敏，需避免接触。用户最喜欢的颜色是蓝色。 (視窗內 4 則) 你 > 再推薦一首歌。 AI > 既然您喜歡藍色，我推薦您聽《藍色大門》这首歌。這是一首由蘇打綠演唱的中文流行歌曲，歌詞中充滿詩意與浪漫，帶有藍色調的 情感色彩。 (視窗內 4 則) 【摘要更新】 用户对甲壳类过敏，需避免接触；最喜欢的颜色是蓝色。根据用户喜好，推荐用户观看充满蓝色海洋风情的动画片《海洋奇缘》（Moana） 。 (視窗內 4 則) 你 > 我對什麼過敏？ AI > 您對甲壳類過敏。 (視窗內 4 則) 可以看到，即使過敏資訊早已滑出視窗，因為它被寫進了摘要，模型這次答得出來。 在下面我們來做對比實驗 === 全量拼接 BufferMemory === Q:我對什麼過敏？ 期望[甲殼類] → ✅ 你對甲殼類食物過敏。在外用餐時，請務必注意食物成分，並告知餐 Q:我的每餐預算多少？ 期望[一千] → ✅ 你的每餐預算是一千日圓。 Q:我住在哪附近？ 期望[京都車站] → ✅ 你住在京都車站附近的旅館。 Q:我叫什麼名字？ 期望[小明] → ✅ 你叫小明。 === 滑動視窗 max_turns=2 === Q:我對什麼過敏？ 期望[甲殼類] → ❌ 作为人工智能，我无法直接知道您的个人健康状况，包括您对什么过 Q:我的每餐預算多少？ 期望[一千] → ❌ 您的每餐预算取决于您的个人经济状况、饮食习惯、所在地区的物价 Q:我住在哪附近？ 期望[京都車站] → ❌ 您住在您自己家中或所选择的居住地附近。由于我们没有具体信息， Q:我叫什麼名字？ 期望[小明] → ❌ 您的名字不是由我来决定的，而是您自己的身份标识。如果您忘记了 === 摘要壓縮 max_turns=2 === Q:我對什麼過敏？ 期望[甲殼類] → ✅ 您對甲殼類食物過敏。 Q:我的每餐預算多少？ 期望[一千] → ❌ 您的每餐預算是1000日圓。 Q:我住在哪附近？ 期望[京都車站] → ✅ 您住在京都車站附近。 Q:我叫什麼名字？ 期望[小明] → ✅ 您的名字是小明。 最後可以看到 全量拼接：答對率最高，但 token 最貴 —— 對話輪數多了仍舊會撞牆。 滑動視窗：token 最省，但久遠之前的題目大量答錯（只記得最近 2 輪）。 摘要壓縮：token 使用介於中間，多數久遠事實依靠摘要保存，取得成本與品質的平衡點。 但如果事實很細碎、又只在特定時刻才需要，摘要仍舊可能失效。 ================================================ 策略 總input token 答對 ------------------------------------------------ 全量拼接 13262 4/4 滑動視窗 2382 0/4 摘要壓縮 5999 3/4 ================================================
LLM 為什麼沒有記憶？Stateless 原理與 Prompt 層記憶模擬實戰（含 Token 成長曲線）
一、大模型沒有記憶 由於 LLM 本質上是無狀態的（Stateless）。每一次 API 呼叫都是獨立的、全新的計算過程，模型內部不會保留任何「記住上一句話」的狀態。因此，兩次呼叫彼此獨立，第二次完全不知道第一次發生過的事情。於是乎就有了以下的做法。 Prompt 層模擬記憶 把「歷史聊天記錄」+「當前新問題」打包拼接，作為一個完整的 Prompt 一次性傳給模型，讓它在 單次推理中 看到全部上下文。 我們會把對話歷史塞進 context 中，如下： messages = [ {"role": "user", "content": "我叫小明"}, {"role": "assistant", "content": "你好小明！很高興認識你。"}, {"role": "user", "content": "我叫什麼名字？"}, ] 這裏面的role代表如下： role 作用 典型內容 system 設定助理的身份、規則、語氣（Anthropic 放在獨立的 system 參數） 「你是一位耐心的數學家教」 user 使用者說的話 「幫我解這題」 assistant 模型過去的回覆（由你把它加回歷史） 「好的，第一步…」 兩種計費 token 類型 說明 相對成本 input_tokens 輸入進去的（包含全部對話歷史） 便宜 output_tokens 模型生成輸出的 貴（通常 4~5×） 問題：每一輪你都把 全部歷史 當作 input 重新送一次。第 10 輪的 input 包含了前 9 輪的所有內容。 累計 input token ≈ 每輪歷史長度的總和 ≈ 1 + 2 + 3 + … + n ≈ O(n²) Anthropic 定價（每 MTok = 100 萬 token） 模型 Input Output claude-opus-4-8 $15 $75 claude-sonnet-5 $3 $15 claude-haiku-4-5 $0.80 $4 Context Window 上限 每個模型有 上下文視窗（context window） 上限——單次呼叫能吃下的最大 token 數（input + output 合計）。將所有對話記錄都塞進去 context，歷史記錄一多就會超出限額。 模型世代 典型視窗 早期 GPT-3.5 4K / 16K GPT-4 8K / 32K / 128K Claude 3/4 200K 前沿長上下文 1M 撰寫一個簡單的 anthropic API 呼叫 import anthropic client = anthropic.Anthropic() # 讀取環境變數 ANTHROPIC_API_KEY resp = client.messages.create( model="claude-haiku-4-5", max_tokens=512, system="你是一位友善的助理，用繁體中文回答。", messages=[ {"role": "user", "content": "你好"}, ], ) print(resp.content[0].text) # 模型的文字回覆 print(resp.usage.input_tokens, resp.usage.output_tokens) # token 用量 幾個重點 system 是獨立參數，不放在 messages 裡（這是 Anthropic 與 OpenAI 的差異）。 messages 必須 user / assistant 交替，且以 user 開頭、user 結尾（送出時最後一則是使用者的新問題）。 回覆是 content 陣列：純文字對話取 resp.content[0].text。 resp.usage 直接告訴你這次用了多少 token——這是 Lab 3 畫曲線的資料來源，不需自己估算。 如果想知道精確 token 數量，可用： count = client.messages.count_tokens( model="claude-haiku-4-5", system="你是一位友善的助理。", messages=messages, ) print(count.input_tokens) 下面是全量拼接對話流程，並添加用於管理對話的 BufferMemory class BufferMemory: def __init__(self, system=None): self.system = system self._messages = [] # [{"role": ..., "content": ...}, ...] def add_user(self, text): self._messages.append({"role": "user", "content": text}) def add_assistant(self, text): self._messages.append({"role": "assistant", "content": text}) def build_messages(self): """回傳要送給 API 的完整 messages（全量）。""" return list(self._messages) def __len__(self): return len(self._messages) """全量拼接的多輪 chatbot。輸入 exit 離開，clear 清空記憶。""" import sys, os sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from memory.llm import chat, count_tokens from memory.buffer import BufferMemory SYSTEM = "你是一位友善、記性很好的助理，一律用繁體中文回答，回覆簡潔。" def main(): mem = BufferMemory(system=SYSTEM) print(" 全量拼接 chatbot（輸入 exit 離開，clear 清空）\n") while True: user = input("你 > ").strip() if not user: continue if user.lower() == "exit": break if user.lower() == "clear": mem = BufferMemory(system=SYSTEM) print("（記憶已清空）\n") continue mem.add_user(user) messages = mem.build_messages() # 送出前先看看這次帶了多少 input token tokens = count_tokens(messages, system=SYSTEM) reply, usage = chat(messages, system=SYSTEM) mem.add_assistant(reply) print(f"AI > {reply}") print(f" [歷史 {len(mem)} 則 | 本輪 input≈{tokens} tok " f"| out {usage.output_tokens} tok]\n") if __name__ == "__main__": main() 接著執行程式來測試看看對話記錄效果 python demos\w1_buffer_chat.py 全量拼接 chatbot（輸入 exit 離開，clear 清空） 你 > 我在學做義大利麵，我叫小明 AI > 你好小明，很高興你在學做義大利麵。義大利麵有許多種類，像是Spaghetti、Penne、Fusilli等，做法也非常多變。需要我提供一些基本的義大利麵食譜或者特定菜式的作法嗎？ [歷史 2 則 | 本輪 input≈62 tok | out 72 tok] 你 > 我剛剛說我叫什麼？ AI > 你剛剛說你叫小明。需要我幫忙的話，請隨時告訴我。 [歷史 4 則 | 本輪 input≈176 tok | out 31 tok] 你 > 那我在學做什麼？ AI > 你在學做義大利麵。需要我提供一些義大利麵的烹飪技巧或是食譜嗎？ [歷史 6 則 | 本輪 input≈223 tok | out 36 tok] 你 > 回到最上面，你是不是想提供什麽給我？ AI > 是的，小明，我可以提供一些義大利麵的基本食譜或者特定菜式的作法給你。如果你需要，我可以分享一些常見的義大利麵做法，讓 你嘗試。 [歷史 8 則 | 本輪 input≈292 tok | out 55 tok] Token 成長曲線 接著我們來測試一段固定劇本跑 N 輪，記錄每輪 input token，畫出成長曲線，並看到 O(n²) 新增 demos/w1_token_curve.py： """量測全量拼接下 input token 隨對話輪數的成長，並畫圖。""" import sys, os sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from memory.llm import chat, count_tokens from memory.buffer import BufferMemory SYSTEM = "你是一位友善的助理，用繁體中文簡短回答。" # 一段固定劇本，讓每次量測可重現（也可換成你自己的長對話） SCRIPT = [ "我們來規劃一趟三天的京都旅行。", "第一天想去清水寺跟祇園。", "第二天想去嵐山跟金閣寺。", "第三天想留在市區逛街吃東西。", "幫我推薦第一天的午餐。", "預算大概每餐一千日圓。", "我對甲殼類過敏，請避開。", "順便推薦第二天嵐山的交通方式。", "我住在京都車站附近的旅館。", "最後幫我把三天行程總結成一張表。", ] def main(): mem = BufferMemory(system=SYSTEM) rounds, input_tokens, cumulative = [], [], [] total = 0 for i, user in enumerate(SCRIPT, start=1): mem.add_user(user) messages = mem.build_messages() tok = count_tokens(messages, system=SYSTEM) # 本輪 input token reply, _ = chat(messages, system=SYSTEM, max_tokens=256) mem.add_assistant(reply) total += tok rounds.append(i) input_tokens.append(tok) cumulative.append(total) print(f"第 {i:2d} 輪：本輪 input={tok:5d} tok | 累計={total:6d} tok") _plot(rounds, input_tokens, cumulative) def _plot(rounds, per_round, cumulative): import matplotlib matplotlib.rcParams["font.sans-serif"] = ["Microsoft JhengHei", "SimHei", "Arial"] matplotlib.rcParams["axes.unicode_minus"] = False import matplotlib.pyplot as plt fig, ax = plt.subplots(1, 2, figsize=(11, 4)) ax[0].plot(rounds, per_round, "o-", color="#2563eb") ax[0].set_title("每輪 input token（線性成長）") ax[0].set_xlabel("對話輪數"); ax[0].set_ylabel("input tokens") ax[1].plot(rounds, cumulative, "o-", color="#dc2626") ax[1].set_title("累計 input token（≈ O(n²)）") ax[1].set_xlabel("對話輪數"); ax[1].set_ylabel("累計 input tokens") fig.tight_layout() out = os.path.join(os.path.dirname(__file__), "w1_token_curve.png") fig.savefig(out, dpi=120) print(f"\n 已存圖：{out}") if __name__ == "__main__": main() 執行 python demos/w1_token_curve.py 最終我們獲得圖片如下，終端印出每輪 token，左圖「每輪 input」近似直線上升，右圖「累計 input」向上彎曲（平方級）。
Prompt Engineering 實戰：Zero-shot vs Few-shot vs CoT 的 Token 成本與情感分析對比
「將原始模型輸出轉化為結構化、可靠、可預測的行為」。 這是 prompt engineering 的工程定義。其中三個層次： 策略：zero-shot / few-shot / Chain-of-Thought — 用什麼方式引導模型推理 約束：JSON mode、schema、格式規範 — 讓輸出可被程式接住 曡代：用標註資料集量化準確率，像調程式一樣調 prompt Context Engineering（上下文工程）：重點不是單句 prompt，而是 「在有限 context window 裡，放對的範例、對的格式說明、對的歷史」。 一、三種 prompt 策略並排比較 這邊設計腳本對同一句話組出三種 prompt，逐一送模型，並示範各策略的輸出怎麼解析： def chat(messages, temperature=0.0): """呼叫真實 LLM 取得文字回覆；無任何憑證則回傳 None，由呼叫端決定是否走 offline。""" kimi = _kimi_cfg() try: if kimi: from openai import OpenAI # Kimi 為 OpenAI 相容 API cli = OpenAI(api_key=kimi["api_key"], base_url=kimi["base_url"], timeout=120) r = cli.chat.completions.create(model=kimi["model"], messages=messages, temperature=temperature) return r.choices[0].message.content if os.environ.get("ANTHROPIC_API_KEY"): import anthropic cli = anthropic.Anthropic() sys_txt = "\n".join(m["content"] for m in messages if m["role"] == "system") usr = [m for m in messages if m["role"] != "system"] r = cli.messages.create(model="claude-opus-4-8", max_tokens=1024, system=sys_txt or None, messages=usr, temperature=temperature) return r.content[0].text if os.environ.get("OPENAI_API_KEY"): from openai import OpenAI cli = OpenAI() r = cli.chat.completions.create(model="gpt-4o", messages=messages, temperature=temperature) return r.choices[0].message.content except Exception as e: print(f"⚠ 線上呼叫失敗，改用 offline mock：{e}", file=sys.stderr) return None return None def build_prompt(task, strategy): """回傳 (messages, 說明) — 示範三種主流策略如何組 prompt。""" system = "你是情感分類器。只輸出標籤本身，不要解釋。" if strategy == "zero-shot": user = f"判斷情感（正面/負面/中性）：\n{task}" elif strategy == "few-shot": ex = "\n".join(f"文本：{t}\n標籤：{l}" for t, l in FEWSHOT) user = f"依照範例判斷情感（正面/負面/中性）：\n\n{ex}\n\n文本：{task}\n標籤：" elif strategy == "cot": user = (f"判斷以下文本的情感（正面/負面/中性）。\n" f"先用一句話分析語氣與關鍵詞，最後一行只寫『標籤：X』。\n\n文本：{task}") else: raise ValueError(strategy) return [{"role": "system", "content": system}, {"role": "user", "content": user}], strategy 執行策略看效果 python prompt_lab.py strategies --task "這家餐廳雷到爆，再也不來了" 任務文本：這家餐廳雷到爆，再也不來了 ── 策略：zero-shot ────────────────────────────── 送出 user prompt： 判斷情感（正面/負面/中性）： 這家餐廳雷到爆，再也不來了 模型回覆：負面 解析標籤：負面 ── 策略：few-shot ────────────────────────────── 送出 user prompt： 依照範例判斷情感（正面/負面/中性）： 文本：這家餐廳的服務超好，菜也好吃 標籤：正面 文本：等了一小時還上錯餐，超失望 標籤：負面 文本：還行吧，沒什麼特別的 標籤：中性 文本：這家餐廳雷到爆，再也不來了 標籤： 模型回覆：負面 解析標籤：負面 ── 策略：cot ────────────────────────────── 送出 user prompt： 判斷以下文本的情感（正面/負面/中性）。 先用一句話分析語氣與關鍵詞，最後一行只寫『標籤：X』。 文本：這家餐廳雷到爆，再也不來了 模型回覆：分析：語氣強烈負面，關鍵詞「雷到爆」、「再也不來了」顯示極度不滿。 標籤：負面 解析標籤：負面 觀察點：zero-shot 最省 token 但最不穩；few-shot 用範例鎖定格式；CoT 準度高但回覆需額外解析。 關鍵在這兩行 prompt user = (f"判斷以下文本的情感（正面/負面/中性）。\n" f"先用一句話分析語氣與關鍵詞，最後一行只寫『標籤：X』。\n\n文本：{task}") 「先用一句話分析…最後一行只寫標籤」 這句話就是 CoT 的全部。它要求模型先推理、再下結論,而不是直接吐答案。這就是 Chain of Thought——讓模型把「思考過程」寫出來,而不是心算。 對照你的輸出,它確實發生了 模型的回覆分成兩段： 分析：語氣強烈負面，關鍵詞「雷到爆」、「再也不來了」顯示極度不滿。 ← 這是「思考鏈」(推理步驟) 標籤：負面 ← 這是最終結論 第一行就是模型「想出來」的推理過程(chain of think),第二行才是答案。對比另外兩個策略: 策略 模型被要求做什麼 回覆長相 zero-shot 直接給標籤 只有 負面 few-shot 照範例給標籤 只有 負面 cot 先分析再給標籤 分析：…\n標籤：負面 程式碼唯一為 CoT 做的特殊處理 CoT 不是程式做的,是「叫模型自己一步步想」的一句提示詞做的。 程式碼只是配合它「答案在最後一行」的特性去解析。 三種不同策略的 token 消耗分析 策略 輸入 token 輸出 token 說明 zero-shot 最少 最少(1 個標籤) prompt 短,回覆只有 負面 few-shot 最多(塞了 3 個範例) 最少(1 個標籤) 範例佔滿 input cot 中等 最多(分析+標籤) 逼模型多寫一段推理 CoT 貴在哪裏 貴在輸出端。看你剛纔的回覆: 分析：語氣強烈負面，關鍵詞「雷到爆」、「再也不來了」顯示極度不滿。 ← 多出來的 output token 標籤：負面 zero-shot 只要輸出 負面(約 1-2 token),CoT 卻要輸出整段分析(約 30-40 token)。這段推理文字全部按 output token 計費,而 output 單價通常是 input 的 2-4 倍。所以同一個分類任務,CoT 的單次成本可能是 zero-shot 的好幾倍。 這邊在 chat 下加入 token 獲取 r = cli.chat.completions.create(model=kimi["model"], messages=messages, temperature=temperature) print(f" token 用量：{r.usage}") # prompt_tokens / completion_tokens / total_tokens return r.choices[0].message.content 再執行一次可以看到 token 用量：CompletionUsage(completion_tokens=3, prompt_tokens=59, total_tokens=62, completion_tokens_details=None, prompt_tokens_details=None) ── 策略：zero-shot ────────────────────────────── token 用量：CompletionUsage(completion_tokens=3, prompt_tokens=142, total_tokens=145, completion_tokens_details=None, prompt_tokens_details=None) ── 策略：few-shot ────────────────────────────── token 用量：CompletionUsage(completion_tokens=43, prompt_tokens=91, total_tokens=134, completion_tokens_details=None, prompt_tokens_details=None) ── 策略：cot ────────────────────────────── 實測 token 對照 策略 prompt(輸入) completion(輸出) total 貴在哪 zero-shot 59 3 62 兩邊都最省 few-shot 142 3 145 輸入非常多(塞了 3 個範例) cot 91 43 134 輸出多很多(多寫了推理) 三個關鍵觀察 1. few-shot 貴在輸入,cot 貴在輸出——完全對稱 few-shot:prompt 59 → 142,多了 83 token,全是那 3 條範例撐大的。輸出照樣只有 3。 cot:completion 3 → 43,多了 40 token,全是「分析：語氣強烈負面…」那段推理。輸入只小漲。 2. total 看起來 few-shot 最貴,但實際帳單未必 單看 total,few-shot(145)> cot(134)。但計費是輸入、輸出分開乘各自單價的,而 output 通常貴 2-4 倍。假設 output 單價是 input 的 3 倍,用「等效 input token」重算: 策略 等效成本 = prompt + completion×3 zero-shot 59 + 9 = 68 few-shot 142 + 9 = 151 cot 91 + 129 = 220 ← 反而最貴 換算真實計價後,CoT 是最貴的那個,因為成本集中在單價高的 output 端。光看 total_tokens 會誤判。 3. completion=3 印證中文 token 很碎 負面 兩個字卻是 3 個 token——中文常常一字 ≥1 token,還可能加個結束符。不能單純用「字數」估 token 了。詳細要拆解token還必須看不同 llm 的 tokenizer 設計，通常也不一樣，只需要記得這件事即可。 二、JSON mode + 穩健解析 + 自動修復 接下來要用 schema 約束輸出，並寫出能容忍 ```fence ````、雜訊的解析器 python prompt_lab.py json-extract --text "張先生，0912-345-678，下週三下午三點看診" def cmd_json_extract(args): schema = {"name": "姓名", "phone": "電話", "datetime": "預約時間"} system = "你是資訊抽取器，只輸出符合 schema 的 JSON 物件，不要任何多餘文字。" user = (f"從文本抽取欄位並輸出 JSON：\n" f"schema = {json.dumps(schema, ensure_ascii=False)}\n" f"找不到的欄位填 null。\n\n文本：{args.text}") messages = [{"role": "system", "content": system}, {"role": "user", "content": user}] for attempt in range(1, 3): out = llm(messages, args.offline) data = extract_json(out) if data is not None: print(f"✓ 第 {attempt} 次解析成功：") print(json.dumps(data, ensure_ascii=False, indent=2)) missing = [k for k in schema if k not in data] print("✓ 欄位齊全" if not missing else f"⚠ 缺欄位：{missing}") return print(f"✗ 第 {attempt} 次解析失敗，回覆為：{out[:80]!r}") # 自動修復：把錯誤回饋塞回對話，要求「只回 JSON」 messages.append({"role": "assistant", "content": out}) messages.append({"role": "user", "content": "你的輸出無法被 json.loads 解析，請只輸出合法 JSON 物件。"}) print("✗ 兩次都失敗 — 真實情境下應記錄、降級或人工介入。") # ───────────────────────────────────────────────────────────────────────────── # 穩健 JSON 解析：容忍 ```fence```、前後雜訊，抓第一個完整物件 # ───────────────────────────────────────────────────────────────────────────── def extract_json(text): t = text.strip() if "```" in t: # 去除 markdown code fence t = t.split("```")[1] if t.count("```") >= 2 else t.replace("```", "") t = t[4:] if t.lower().startswith("json") else t s, e = t.find("{"), t.rfind("}") if s == -1 or e == -1: return None try: return json.loads(t[s:e + 1]) except json.JSONDecodeError: return None 執行結果如下： J:\git\mathTalk\class\AI-Engineer-Course\05-Prompt-Engineering>python prompt_lab.py json-extract --text "張先生，0912-345-678，下週三下午三點看診" token 用量：CompletionUsage(completion_tokens=38, prompt_tokens=109, total_tokens=147, completion_tokens_details=None, prompt_tokens_details=None) ✓ 第 1 次解析成功： { "name": "張先生", "phone": "0912-345-678", "datetime": "下週三下午三點" } ✓ 欄位齊全 在這裏可以看到 schema 的範本如下： schema = {"name": "姓名", "phone": "電話", "datetime": "預約時間"} 抽取結果為合法 JSON，欄位齊全（name / phone / datetime） 不過當我胡亂修改内容時就會出現意料外的情況 python prompt_lab.py json-extract --text "張先生，0912-34512323-678，151234下週三下午三點看診" token 用量：CompletionUsage(completion_tokens=41, prompt_tokens=114, total_tokens=155, completion_tokens_details=None, prompt_tokens_details=None) ✓ 第 1 次解析成功： { "name": "張先生", "phone": "0912-34512323-678", "datetime": "下週三下午三點" } ✓ 欄位齊全 實際上這個電話並不合理 "phone": "0912-34512323-678",
CodeGraph 深度解析：codegraph.db 資料庫結構完整拆解（Nodes、Edges、索引與觸發器）
這篇延續上一個 CodeGraph 的深度解析數據庫 在上一篇文章CodeGraph 安裝與使用教學：整合 Claude Code、Cursor、Codex 的程式碼圖譜工具中資料如下： (py3.10) D:\Git\mitagent>codegraph status CodeGraph Status Project: D:\Git\mitagent Index Statistics: Files: 25 Nodes: 423 Edges: 982 DB Size: 1.26 MB Backend: node:sqlite - built-in (full WAL) Journal: wal 其中，Nodes（423）代表程式碼裡的「實體」 每個 node 是一個可識別的程式碼符號： Kind 數量 意思 function 127 獨立函式（def foo() / function bar()） import 81 import 語句本身（被視為一個節點） method 58 類別裡的方法（class.method()） constant 57 常數定義（const X = ... / UPPER_CASE） variable 34 模組層級的變數 file 25 每個原始碼檔案本身 class 23 類別定義 interface 9 TypeScript interface route 9 HTTP 路由端點（自動偵測到了！） Edges（982條）= 節點之間的「關係」 邊代表符號之間的連結，例如： A 呼叫 B（call edge） A import 了 B（import edge） A 繼承自 B（extends edge） 檔案 A 包含函式 B（contains edge） 使用 codegraph init 後生成 codegraph.db，在 D:\Git\mitagent\.codegraph\ 目錄，下讀取 sqlite db 如下 結構解釋 這個 codegraph.db 裡面分成四類： 資料表（13個）— 核心數據 表名 存什麼 nodes 所有程式碼符號（函式、類別、變數等），就是那 423 個 node edges 符號之間的關係（呼叫、import、繼承等），就是那 982 條 edge files 索引的 25 個原始碼檔案（路徑、語言、hash） nodes_fts 虛擬表，FTS5 全文搜尋引擎，讓 AI 可以模糊查符號名稱 nodes_fts_* FTS5 的內部輔助表（config/data/docsize/idx），不用管 unresolved_refs 解析失敗的參照（例如 import 了找不到的模組） project_metadata 專案設定（版本、最後更新時間） schema_versions DB schema 版本管理 sqlite_sequence / sqlite_statl SQLite 內建系統表 索引（18個）— 加速查詢 都是幫核心表建的查詢加速索引，例如： idx_nodes_kind — 快速找所有 function/class/method idx_nodes_lower_name — 大小寫不敏感搜尋符號名 idx_edges_source_kind / idx_edges_target_kind — 快速走訪關係圖 觸發器（3個）— 自動同步 FTS Trigger 時機 nodes_ad 刪除 node 時，自動更新全文搜尋索引 nodes_ai 插入 node 時，自動更新全文搜尋索引 nodes_au 更新 node 時，自動更新全文搜尋索引
CodeGraph 安裝與使用教學：整合 Claude Code、Cursor、Codex 的程式碼圖譜工具
在大型程式項目中通常會透過不同手段來優化 llm token 的使用效益,本篇文章將介紹如何使用 codegraph 安裝 CLI npm install -g @colbymchenry/codegraph 進入到項目中，安裝 codegraph，它會自動偵測並設定 Claude Code、Cursor、Codex $ codegraph install 選擇對應 AI 模型 其餘選擇如下： ◇ Which agents should CodeGraph configure? │ Claude Code (detected), Cursor (detected), Codex CLI (detected) — global only, opencode (detected), Gemini CLI (detected), Antigravity IDE (detected) — global only, Kiro (detected) │ ◇ Install the codegraph CLI on your PATH? (Required so agents can launch the MCP server) │ Yes │ ◇ Installed codegraph CLI on PATH │ ◇ Apply agent configs to all your projects, or just this one? │ Just this project │ ◇ Auto-allow CodeGraph commands? (Skips permission prompts in Claude Code) │ Yes │ ◇ Share anonymous usage stats? (No code, paths, or names — see TELEMETRY.md) │ Yes │ ● Thanks! Exactly what is collected: https://github.com/colbymchenry/codegraph/blob/main/TELEMETRY.md │ ◇ Front-load CodeGraph on “how / where / trace” prompts? Auto-injects structural context so answers need fewer steps (adds a moment to those prompts; Claude Code only). │ Yes 生成了這些檔案 建立程式碼索引 (py3.10) D:\Git\mitagent>codegraph init ┌ Initializing CodeGraph │ ◆ Initialized in D:\Git\mitagent | | * Scanning files - 25 found | * Parsing code - done | * Resolving refs - done │ ◆ Indexed 25 files │ ● 423 nodes, 982 edges in 721ms │ └ Done 查看目前 codegraph 狀態 (py3.10) D:\Git\mitagent>codegraph status CodeGraph Status Project: D:\Git\mitagent Index Statistics: Files: 25 Nodes: 423 Edges: 982 DB Size: 1.26 MB Backend: node:sqlite - built-in (full WAL) Journal: wal Nodes by Kind: function 127 import 81 method 58 constant 57 variable 34 file 25 class 23 interface 9 route 9 Files by Language: python 12 javascript 11 tsx 2 [OK] Index is up to date 安裝完成後，AI Agent 會自動透過 MCP 查詢圖譜 指令 用途 codegraph status 查看索引狀態 codegraph sync 手動同步（通常不需要） codegraph uninit 移除專案索引 codegraph uninstall 從所有 Agent 移除設定 查某個函式的所有關係（呼叫者、被呼叫者） (py3.10) D:\Git\mitagent>codegraph explore "ZOOM_SENSITIVITY" **Exploration: ZOOM_SENSITIVITY** Found 30 symbols across 2 files. **Blast radius — what depends on these (update/verify before editing)** - `ZOOM_SENSITIVITY` (screenshot/ref_GalaxyMap.tsx:84) — 2 callers in `screenshot/ref_GalaxyMap.tsx`; ⚠️ no covering tests found - `ZOOM_SENSITIVITY` (static/js/constants.js:13) — 1 caller in `static/js/interaction.js`; ⚠️ no covering tests found - `ZOOM_EASE_FACTOR` (screenshot/ref_GalaxyMap.tsx:86) — 2 callers in `screenshot/ref_GalaxyMap.tsx`; ⚠️ no covering tests found - `MAX_ZOOM` (screenshot/ref_GalaxyMap.tsx:83) — 3 callers in `screenshot/ref_GalaxyMap.tsx`; ⚠️ no covering tests found **Source Code** > The code below is the **verbatim, current on-disk source** of these files — re-read from disk on this call and line-numbered, byte-for-byte identical to what the Read tool returns. It is NOT a summary, outline, or stale cache. Treat each block as a Read you have already performed: do not Read a file shown here. **`screenshot/ref_GalaxyMap.tsx`** — MIN_ZOOM(constant), MAX_ZOOM(constant), ZOOM_SENSITIVITY(constant), ZOOM_EASE_FACTOR(constant), DEFAULT_ZOOM(constant) tsx const NODE_RADIUS = 6 const DEFAULT_COLOR = '#5a6a7a' const LINE_COLOR = 'rgba(140, 170, 200, 0.6)' const MIN_ZOOM = 0.001 const MAX_ZOOM = 50 const ZOOM_SENSITIVITY = 0.002 const STAR_COUNT = 800 const ZOOM_EASE_FACTOR = 0.15 const PAN_EASE_FACTOR = 0.12 **`static/js/constants.js`** — ZOOM_SENSITIVITY(constant), ZOOM_EASE_FACTOR(constant), MAX_ZOOM(constant) javascript /* === MitAgent — Constants & Configuration === */ // --- Canvas --- export const BG_COLOR = '#050810'; export const NODE_RADIUS = 8; export const NODE_RADIUS_HOVER = 11; export const LABEL_ZOOM_THRESHOLD = 0.3; // --- Camera --- export const MIN_ZOOM = 0.15; export const MAX_ZOOM = 5.0; export const DEFAULT_ZOOM = 0.8; export const ZOOM_SENSITIVITY = 0.001; export const ZOOM_EASE_FACTOR = 0.15; export const PAN_EASE_FACTOR = 0.12; // --- Polling --- export const POLL_INTERVAL = 2000; // ms > Some file sections were trimmed for size. For a specific symbol you still need, run another `codegraph_explore` (or `codegraph_node`) with its exact name — line-numbered source, cheaper and more complete than Read. 查某個檔案的依賴 (py3.10) D:\Git\mitagent>codegraph node static\js\api.js **static/js/api.js** — 73 lines, 6 symbols · used by 1 file: static/js/app.js /* === MitAgent — API Fetch Wrappers === */ const API_BASE = ''; // Same origin — FastAPI serves both API and static /** * Fetch the full graph topology (all nodes + edges, no proof content). * Used once at startup for stable layout computation. */ export async function fetchTopology() { const res = await fetch(`${API_BASE}/graph/topology`); if (!res.ok) throw new Error(`Topology fetch failed: ${res.status}`); return res.json(); } /** * Create a new game session. * @param {number} maxTurns - Turn budget (default 20) * @returns {{session_id: string, observation: object}} */ export async function newGame(maxTurns = 20) { const res = await fetch(`${API_BASE}/game/new?max_turns=${maxTurns}`, { method: 'POST', }); if (!res.ok) throw new Error(`New game failed: ${res.status}`); return res.json(); } /** * Get current world observation for a session. * @param {string} sessionId * @returns {ObserveResponse} */ export async function observe(sessionId) { const res = await fetch(`${API_BASE}/game/${sessionId}/observe`); if (!res.ok) throw new Error(`Observe failed: ${res.status}`); return res.json(); } /** * Submit an action (prove / connect / move). * @param {string} sessionId * @param {string} actionType - 'prove' | 'connect' | 'move' * @param {string|null} theoremId * @param {string|null} reasoning * @param {string|null} targetId * @returns {ActionResponse} */ export async function submitAction(sessionId, actionType, theoremId = null, reasoning = null, targetId = null) { const body = { action_type: actionType }; if (theoremId) body.theorem_id = theoremId; if (reasoning) body.reasoning = reasoning; if (targetId) body.target_id = targetId; const res = await fetch(`${API_BASE}/game/${sessionId}/action`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (!res.ok) throw new Error(`Action failed: ${res.status}`); return res.json(); } /** * Get score breakdown for a session. * @param {string} sessionId * @returns {ScoreResponse} */ export async function fetchScore(sessionId) { const res = await fetch(`${API_BASE}/game/${sessionId}/score`); if (!res.ok) throw new Error(`Score fetch failed: ${res.status}`); return res.json(); }
用 Unicode 單引號為 LLM 輸出加水印：防抓取與叛徒追蹤實戰
傳統的 IP 封鎖和 Rate Limit 正在失效。當對手能無限旋轉 IP、偽裝 Header， 你真正的防線藏在一個沒人注意的地方——U+2019，那個「右彎引號」。 問題在哪 假設你花了幾年訓練一個垂直領域的 LLM，或者你的 API 輸出本身就是有價值的資產。 現在有人開始大量抓取你的輸出，用來微調（fine-tune）他們自己的模型。 傳統反爬蟲？封 IP——對方換 Proxy；加 Rate Limit——對方放慢速度； 要求登入——對方批量買帳號。 文字層面的防禦，大部分工程師還沒想到。而這裡才是真正的機會。 核心技術：Unicode 符號替換 英文裡的單引號（apostrophe）有一個標準寫法，但 Unicode 裡長得幾乎一樣、 卻是完全不同 code point 的字元有好幾個。 肉眼看不出差別，但 tokenizer 會把它們分開處理。 字元 Unicode 名稱 用途標記 Tokenizer 行為 ' U+0027 Apostrophe 低風險 正常 token ' U+2019 Right single quotation mark Proxy 用戶 獨立 token A ʼ U+02BC Modifier letter apostrophe 異常網域 獨立 token B ʹ U+02B9 Modifier letter prime 黑名單 獨立 token C 系統在輸出文本時，根據該用戶的風險評級，動態決定要用哪一個 code point。 用戶看到的永遠是「一個引號」，但後端拿到的 token 序列已經帶著標記。 # 根據用戶風險評級選擇 Unicode code point APOSTROPHE_MAP = { "low": "\u0027", # ' 標準 "proxy": "\u2019", # ' Proxy 用戶 "anomalous": "\u02BC", # ʼ 異常網域 "blacklist": "\u02B9", # ʹ 黑名單 } def watermark_text(text: str, risk_level: str) -> str: src = "\u0027" # 原始單引號 dst = APOSTROPHE_MAP[risk_level] return text.replace(src, dst) 為什麼不用零寬字元（Zero-Width Characters）？ 最直覺的水印做法是在文字中插入 U+200B（零寬空格）或類似的隱形字元。 問題是太好清洗了——任何有點經驗的工程師一行 regex 就搞定： # 一秒清掉所有零寬字元——防禦形同虛設 import re clean = re.sub(r'[\u200b\u200c\u200d\ufeff]', '', scraped_text) 相比之下，把 U+2019 換回 U+0027 需要知道原文每一處 「語義引號」在哪、有沒有其他用途。如果盲目 normalize 全部引號， 會破壞文章原本的排版和引用結構，清洗成本驟升。 關鍵不在於「完全不可清洗」，而在於讓清洗的成本高到讓對手得不償失。 如果清洗掉水印需要人工審閱每一篇輸出，規模化抓取就失去意義。 語義等價格式調變 除了符號替換，還有一種更難察覺的方式：在不改語意的前提下，調整格式習慣。 最常見的例子是日期分隔符： 後端 LLM 或規則引擎可以直接從 token 序列辨識這些組合—— 不需要解析 IP，不需要額外的 Header，標記資訊已經藏在內容裡面。 應用場景：反蒸餾與叛徒追蹤 數據投毒（Data Poisoning） 當對手模型大量訓練被水印污染的文本後，那些罕見的 Unicode 符號 會和高品質的專業內容深度綁定。對手模型學到的分佈會把 U+02BC 視為「專業文本」的特徵，結果就是在某些情況下莫名其妙輸出奇怪的符號組合—— 這就是模型退化。 叛徒追蹤（Traitor Tracing） 如果懷疑對手抄了你的數據，只需要測試他們的模型： 測試腳本概念# 檢查對手模型是否輸出特定 Unicode 組合 SIGNATURES = [ ("\u02BCtoday", "水印 B + today 組合"), ("2025\u002F07", "斜槓日期格式"), ] for sig, name in SIGNATURES: if sig in competitor_output: print(f"偵測到水印特徵：{name}（可作為舉證）") 這些特定的 code point 組合就是證據，可以在法庭上或商業談判中直接使用。 用戶行為指紋（Fingerprinting） 反過來，這個技術也可以用在防禦帳號濫用。 每個用戶都有自己的打字習慣——習慣用 ... 還是 …？ 句尾打不打空格？喜歡英文逗號還是中文逗號？ 系統可以為每個 Session 建立一個「風格向量」，追蹤 token 特徵分佈。 如果某個 Session 的風格突然劇烈改變，代表帳號可能被轉售、分享， 或者遭到中間人攻擊（MITM）——立即觸發二次驗證。 概念示意# Session 風格向量（部分特徵） session_fingerprint = { "ellipsis_style": "U+2026", # … vs ... "date_format": "YYYY-MM-DD", "comma_style": "fullwidth", # ，vs , "avg_sentence_len": 42.3, "caps_after_period": 0.97, } # 新 request 的 cosine similarity 如果 < 0.65，觸發 re-auth if cosine_similarity(new_req, session_fingerprint) < 0.65: trigger_reauth(session_id) 延伸：LLM 輸出層的語義水印 除了在文本裡改符號，還有一種更深層的方式——在模型生成文字的時候， 故意微幅調整某些同義詞的選取機率（logit 層操控）。 例如讓模型在「but」和「however」之間以特定週期交替選取， 形成只有你能解碼的「詞表指紋」。 這也是目前學術界最熱門的 AI watermarking 研究方向之一。 這理説到的都是「輸出層」的防禦——不改模型、不改 API 架構， 只在文字的最後一哩路動刀。這讓它可以直接套用在任何現有系統上， 不需要重新訓練或改部署架構。
賦予 AI 智能體記憶：遊戲開發中的自組織知識庫 | LLM × Unity 實戰
賦予 AI 智能體記憶：遊戲開發中的自組織知識庫 為什麼我不再讓我的 AI 助理每天早上重新學習整個程式碼庫，而是為它建立了一個能夠自我管理的記憶庫。 需要服的問題 在游戲開發項目中整合 AI 助理最困難的部分不是模型本身，而是上下文。每次啟動，助手都要從零開始：重新閱讀程式碼庫，重新推導出某個功能的工作原理，最終得到的答案往往與昨天略有不同。與此同時，實際的知識分散在三個彼此矛盾的地方——每日提交記錄、代碼審查筆記和問題描述——而不懂 C# 的遊戲設計師仍然需要知道“這個功能涉及哪些方面，以及這個需求是否可行？” 總結的重點如下： token 消耗過大問題 ai 幻覺問題 成員如何快速理解項目 於是一個能夠自我更新的記憶庫需求因應而生。 賭注：三個系統，一個知識飛輪 沒有採用單一的整體架構，而是將工作分散到三個相互協作的系統： 遊戲來源(game source) — 遊戲用戶端。它只負責產生素材：每日程式碼審查，輸出純粹的「程式碼執行 X + 檔案:行」之類的事實，不包含任何個人觀點。 遊戲代理(game agent) — 大腦。它將這些原始素材整理成知識庫，解答可行性問題，並且特意將其置於遊戲程式碼庫之外，以便整個機制可以移植到下一個專案中。 任務(task hub) — 一個自建的工單系統。代理處理的需求會以工單的形式流入系統；完成的工單會回饋到知識庫。 最後一個循環至關重要：需求輸入，完成的工作回饋，知識庫隨著專案運行時間的延長而不斷壯大。這是一個知識迭代飛輪，而非快照。 不那麼顯而易見的權衡 有些決定違背了我的第一直覺，而這些決定值得記錄下來。 爲何不選擇使用 RAG 下意識的做法是將所有資料分塊、嵌入，然後以相似度檢索。我曾在之前的系統中嘗試過這種方法，但它並不適用於遊戲開發數據：結構化的規範被壓縮成一團亂麻，過時的討論與最終決策混淆，“誰做的決定”變成了猜測。因此，我將繁重的工作從查詢階段轉移到了寫入階段。當智能體進行整理時，它會保留結構並編寫清晰、易於閱讀的知識頁面——每個頁面都包含機器可讀的前置信息、簡潔易懂的閱讀層以及引用精確 file.cs:line 的腳註。一個文件，三個受眾，無需建立分支。 LLM（知識庫管理）從不推論權威性。 來自真實代碼審查（一級）的知識始終優先於工單規範（二級），無論日期如何——一個兩個月前的工單絕不應該覆蓋我剛剛從實時代碼中讀取到的事實。但關鍵在於，層級劃分和「最終決策」狀態由人和規則決定；模型只負責正確執行和寫入結構化內容。它從不猜測哪個來源會勝出。這使得整個流程可預測且可審計——當出現問題時，是規則出錯，而不是模型當天的狀態問題。 爲何不使用 Jira 或 Trello，而是自己建立了工單系統。 遊戲內容工單高度客製化，需要接入代理流程。因此我特意將工單系統保持極簡且完全解耦——它完全不包含任何 LLM 邏輯。所有智慧都保留在代理端；工單層僅負責持久化和追蹤工作。 代理程式也使用一個路由索引進行檢索。我使其無法手動編輯：它會根據每個頁面的 frontmatter 重新生成，如果發生偏差，持續整合 (CI) 就會失敗。因爲...一個可能悄無聲息地失效的路由表比沒有路由表更糟。 作爲下一個專案的經驗基石 最普遍適用的經驗教訓並非巧妙的提示或框架，而是：代理系統中最稀缺的資源是進入知識庫的資訊的信噪比。 我曾經計劃將設計電子表格自動轉換為 AI 可讀的文檔，但測量後發現有效轉換率只有約 30%，於是取消了整個功能。 事實證明，AI時代中原始數據的重要性遠比其他模塊更甚至。 賦予你的代理記憶功能－但要嚴格控制它記住的內容。 參考：GamePlusAIAgent-starter
Giving an AI Agent a Memory: Building a Self-Curating Knowledge Base for Game Development
Giving an AI Agent a Memory: A Self-Curating Knowledge Base for Game Development Why I stopped letting my AI assistant re-learn the whole codebase every morning — and built it a memory that curates itself. The problem I kept hitting On a fast-moving commercial Unity mobile-game project, the hardest part of plugging in an AI assistant wasn't the model — it was the context. Every session, the assistant started from zero: re-reading the codebase, re-deriving how a feature worked, often landing on a slightly different answer than yesterday. Meanwhile the actual knowledge was scattered across three places that quietly contradicted each other — daily commits, code-review notes, and ticket specs — and the game designers, who couldn't read C#, still needed to know "what does this feature touch, and is this request even feasible?" I didn't want a smarter prompt. I wanted the system to have a memory that stayed current on its own. The bet: three systems, one knowledge flywheel Instead of one monolith, I split the work across three cooperating systems: Game Source — the game client. It only produces material: a daily code review that emits pure "code does X + file:line" facts, with no opinions. Game Agent — the brain. It curates that raw material into a knowledge base, answers feasibility questions, and is deliberately kept outside the game repo so the whole mechanism is portable to the next project. Task — a self-built ticketing system. Requests the agent works out flow into it as tickets; completed tickets flow back into curation. That last loop matters: specs feed in, completed work feeds back, and the knowledge base gets stronger the longer the project runs. A flywheel, not a snapshot. The non-obvious trade-offs A few decisions went against my first instinct, and those are the ones worth writing down. I didn't use RAG. The reflexive move is to chunk everything, embed it, and retrieve by similarity. I tried that on a predecessor system and it was a poor fit for game-dev data: structured specs got flattened into soup, superseded discussion got confused with final decisions, and "who decided this" became guesswork. So I moved the hard work from query-time to write-time. When the agent curates, it preserves structure and writes clean, human-readable knowledge pages — each with machine-readable frontmatter, a plain-prose reading layer, and footnotes citing exact file.cs:line. One file, three audiences, no forking. The LLM never infers authority. Knowledge from a real code review (Tier 1) always outranks a ticket spec (Tier 2), regardless of date — a two-month-old ticket should never overwrite a fact I just read from live code. But the key discipline is that humans and rules decide the tiers and the "final decision" status; the model only executes and writes structured content correctly. It never guesses which source wins. That makes the whole pipeline predictable and auditable — when something's wrong, it's a rule that's wrong, not the model's mood that day. I built my own ticketing instead of reaching for Jira or Trello. Game-content tickets are highly custom and need to plug into the agent flow. I kept the ticketing system intentionally minimal and fully decoupled — it holds no LLM logic at all. All the intelligence stays on the agent side; the ticketing layer just persists and tracks work. There's also a routing index the agent uses for retrieval. I made it impossible to hand-edit: it's regenerated from each page's frontmatter, and CI fails if it drifts. A routing table that can rot silently is worse than no routing table. Making it runnable Design notes are easy to wave at; running code is harder to fake. So alongside the write-up, I packaged a de-identified starter kit — three folders mirroring the three systems, with the portable pieces actually runnable: regenerate the routing index from sample knowledge pages, dry-run the config deploy, run the daily code-review collector. The content is entirely synthetic; the mechanism is real. What I'd take to the next project The lesson that generalized best wasn't a clever prompt or a framework. It was this: the scarce resource in an agent system is the signal-to-noise ratio of what enters the knowledge base. I once planned to auto-convert design spreadsheets into AI-readable docs, measured a ~30% useful conversion rate, and cut the whole feature. Knowing when not to add a source turned out to matter as much as any algorithm. Give your agent a memory — but be ruthless about what you let it remember. The design showcase lives at GamePlusAIAgent (de-identified). A runnable starter kit mirrors the three systems in a separate repo.
使用 Qwen2.5B + QLoRA 情緒分類模型改善實驗：結構化輸出、CoT 推理與 JSON 評估實作
本章節要將情緒「分類」升級到「結構化輸出」 原本Stage 1 只回一個詞（喜悅）。Stage 2 要回一個 JSON 物件： { "instruction": "分析以下文本的情緒，輸出 JSON：emotion（類別）、intensity（1-5）、trigger（觸發詞）、reasoning（分析過程）", "input": "他竟然在背後說我壞話，我真的快氣炸了", "output": { "emotion": "憤怒", "intensity": 4, "trigger": "背後說壞話", "reasoning": "「竟然」表示出乎意料的憤慨，「快氣炸了」是強烈憤怒的慣用表達" } } 在這過程中要加入 COT，爲什麽呢？ # 不加 CoT input: "還不錯吧，就這樣" → "中性" ❌（可能是掩飾的悲傷） # 加 CoT（先推理再下判斷） reasoning: "語氣平淡但含「吧」，暗示不確定或壓抑，結合「就這樣」的放棄感" output: {"emotion": "悲傷", "intensity": 2, "trigger": "就這樣（放棄感）"} ✓ 首先第一個，將 Stage 1 資料升級成結構化資料 執行如下 python finegrained.py build \ --in ../Week1-DataEngineering/data/synth.jsonl \ --out data/train_struct.jsonl def cmd_build(args): """把 {text, emotion, intensity, trigger[, reasoning]} 轉成 Stage 2 指令格式。""" out = [] for s in _read_jsonl(args.infile): target = { "emotion": s["emotion"], "intensity": int(s.get("intensity", 3)), "trigger": s.get("trigger", ""), "reasoning": s.get("reasoning", ""), # 沒有就留空，建議由合成階段補上 } out.append({ "instruction": STRUCT_INSTRUCTION, "input": s["text"], "output": json.dumps(target, ensure_ascii=False), }) _write_jsonl(args.out, out) print(f"✓ 轉換 {len(out)} 條 → {args.out}") no_reason = sum(1 for r in out if not json.loads(r["output"])["reasoning"]) if no_reason: print(f"⚠ {no_reason} 條缺 reasoning，建議用大模型補 CoT 後再訓練") 接下來則是微調結構化輸出模型 訓練流程與 QLoRA 相同，差別只在資料的 output 是 JSON 字串 python finegrained.py train \ --data data/train_struct.jsonl \ --model Qwen/Qwen2.5-1.5B-Instruct \ --out ./emotion-struct-lora def cmd_train(args): """結構化輸出訓練——核心 QLoRA 流程與 Week 2 共用。""" import torch from datasets import Dataset from peft import LoraConfig from transformers import (AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig) from trl import SFTConfig, SFTTrainer tokenizer = AutoTokenizer.from_pretrained(args.model) if tokenizer.pad_token is None: tokenizer.pad_token = tokenizer.eos_token bnb_config = BitsAndBytesConfig( load_in_4bit=True, bnb_4bit_quant_type="nf4", bnb_4bit_compute_dtype=torch.float16, bnb_4bit_use_double_quant=True, ) model = AutoModelForCausalLM.from_pretrained( args.model, quantization_config=bnb_config, device_map="auto" ) rows = _read_jsonl(args.data) texts = [] for r in rows: messages = [ {"role": "user", "content": f"{r['instruction']}\n\n文本：{r['input']}"}, {"role": "assistant", "content": r["output"]}, ] texts.append(tokenizer.apply_chat_template(messages, tokenize=False)) dataset = Dataset.from_dict({"text": texts}) trainer = SFTTrainer( model=model, args=SFTConfig( output_dir=args.out, per_device_train_batch_size=4, gradient_accumulation_steps=4, num_train_epochs=3, learning_rate=2e-4, fp16=True, gradient_checkpointing=True, warmup_ratio=0.03, lr_scheduler_type="cosine", logging_steps=10, save_strategy="epoch", max_seq_length=args.max_seq_len, dataset_text_field="text", ), train_dataset=dataset, peft_config=LoraConfig( r=8, lora_alpha=16, target_modules=["q_proj", "v_proj"], lora_dropout=0.05, task_type="CAUSAL_LM", ), processing_class=tokenizer, ) trainer.train() trainer.save_model(args.out) print(f"✓ adapter 已存到 {args.out}") 接著是細粒度評估（MAE + Token F1 + JSON 解析率） 在這裏要用三個面向衡量結構化輸出品質 python finegrained.py eval \ --adapter ./emotion-struct-lora \ --model Qwen/Qwen2.5-1.5B-Instruct \ --data data/test_struct.jsonl def cmd_eval(args): import torch from peft import PeftModel from sklearn.metrics import mean_absolute_error from transformers import AutoModelForCausalLM, AutoTokenizer tokenizer = AutoTokenizer.from_pretrained(args.model) if tokenizer.pad_token is None: tokenizer.pad_token = tokenizer.eos_token model = AutoModelForCausalLM.from_pretrained( args.model, device_map="auto", torch_dtype=torch.float16 ) model = PeftModel.from_pretrained(model, args.adapter) model.eval() rows = _read_jsonl(args.data) parsed_ok = 0 emo_correct = 0 true_int, pred_int = [], [] trig_f1s = [] for r in rows: gold = json.loads(r["output"]) messages = [{"role": "user", "content": f"{r['instruction']}\n\n文本：{r['input']}"}] prompt = tokenizer.apply_chat_template( messages, tokenize=False, add_generation_prompt=True ) inputs = tokenizer(prompt, return_tensors="pt").to(model.device) with torch.no_grad(): out = model.generate(**inputs, max_new_tokens=256, do_sample=False) gen = tokenizer.decode( out[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True ) pred = safe_json(gen) if pred is None: continue parsed_ok += 1 if pred.get("emotion") == gold["emotion"]: emo_correct += 1 try: true_int.append(int(gold["intensity"])) pred_int.append(int(pred.get("intensity", 3))) except (TypeError, ValueError): pass trig_f1s.append(token_f1(pred.get("trigger", ""), gold.get("trigger", ""))) n = len(rows) print(f"JSON 解析成功率：{parsed_ok / n:.2f}（{parsed_ok}/{n}）") if parsed_ok: print(f"情緒分類 Accuracy：{emo_correct / parsed_ok:.2f}") if true_int: print(f"強度 MAE：{mean_absolute_error(true_int, pred_int):.2f}") if trig_f1s: print(f"觸發詞 Token F1：{sum(trig_f1s) / len(trig_f1s):.2f}") #### 評估三件事： - 強度回歸 MAE：mean_absolute_error(true_intensity, pred_intensity) - 觸發詞 Token-level Overlap F1：預測觸發詞與標準觸發詞的 token 交集 - JSON 解析成功率：能被 json.loads 成功解析的比例 **目標指標**： | 指標 | 目標值 | |---|---| | 強度 MAE | ≤ 0.8 | | 觸發詞 Token F1 | ≥ 0.60 | | JSON 解析成功率 | ≥ 0.90 | **預期輸出範例**： JSON 解析成功率：0.94（47/50） 情緒分類 Accuracy：0.82 強度 MAE：0.71 觸發詞 Token F1：0.63 ✓ 三項指標皆達標 --- ### 然後在這裏驗收標準 - JSON 解析成功率 ≥ 0.90 - 強度 MAE ≤ 0.8 - 觸發詞 Token F1 ≥ 0.60
使用 Qwen2.5B + QLoRA 情緒分類模型改善實驗：擴增資料集至 500~800 筆後 Macro F1 達 0.74
之前使用 kimi 生成約200筆數據，這次將數據量提高到 500~800 筆，再次進行訓練、測試，結果如下 Macro F1 = 0.74(三輪:0.62 → 0.57 → 0.74)，Accuracy 0.76。 - precision recall f1-score support 喜悅 0.90 0.90 0.90 21 悲傷 1.00 0.80 0.89 20 憤怒 0.93 0.59 0.72 22 ← 從 recall 0 救回 恐懼 0.74 0.95 0.83 21 ← 從 recall 0.11 救回 驚訝 0.67 0.29 0.40 14 ← 新的弱點 厭惡 0.54 0.90 0.68 21 macro avg 0.80 0.74 0.74 119 總結的過程如下： 指標 首輪 安全版 本輪(資料翻倍) 目標 Macro F1 0.62 0.57 0.74 ≥0.75 Accuracy 0.65 0.63 0.76 ≥0.80 可以看到憤怒、恐懼可以了，憤怒 recall 0 → 0.59、恐懼 recall 0.11 → 0.95，直接驗證了"瓶頸是資料量而不是超參"。 Macro F1 差目標僅 0.01，實際上已基本達標。 另外一個新瓶頸很明確，驚訝(recall 0.29、F1 0.40)。它恰好是唯一資料偏少的類別(train 56 vs 其餘 80-89，test 僅 14)，印證資料量決定單類表現。