Unity 全螢幕解析度問題排解合集：獨占模式 vs 無邊框模式常見 Bug 與解法
目前常規的PC游戲顯示模式有幾種模式如下， 一、視窗模式（FullScreenMode.Windowed） 二、獨占模式（FullScreenMode.ExclusiveFullScreen） FPS游戲多數使用獨占模式，也是最早的全屏幕模式，性能較好，但使用 Alt+Tab時切換會出現閃頻 三、無邊框模式 （FullScreenMode.FullScreenWindow） 目前游戲多數使用這個模式，開啓後切換不會出現閃屏 問題1、切換解析度時屏幕出現黑屏無訊號 Exclusive Fullscreen 必須使用顯示卡的「原生支援模式」(Display Modes)，Windows 桌面「縮放 / 插值後」能顯示許多非原生模式，但 Unity 的 Exclusive Fullscreen 只能切換到顯示卡真正支援的模式，不能靠 OS 自動補正。 如果顯示卡沒有 真正的 1280×800 模式： Windows 桌面會用 GPU scaling → 顯示正常 Unity Exclusive Fullscreen 嘗試切換 → 找不到完全匹配的 mode → 會改用 fallback（通常 1280×720 或 native resolution） 結果畫面偏移、拉伸或出現 左側黑屏 所以： 你以為進入了 1280×800，但實際上顯卡沒有成功切換到這個 mode。 問題2、兩個屏幕，一個 1080p，一個2k，切換2k解析度時出現錯誤，游戲無法調整到對應的2k解析度 目前沒有處理這個問題。 問題3、無邊框模式切換獨占模式無效 在主要解析度，譬如設備解析度為 1920 X 1080，情況下從無邊框模式切換到獨占模式，雖然切換成功，但實際上卻是無邊框模式，這是 windows 全螢幕最佳化處理的結果，unity無法干涉。 原因是 Windows 10/11 強制 Fullscreen Optimizations（全螢幕最佳化），導致獨占模式被轉成 Borderless Fullscreen Windows 會把要求 Exclusive Fullscreen 的遊戲「包裝成」 ➡ Borderless Fullscreen + 可變刷新率 + 全螢幕優化圖層 這樣能快速切換視窗、疊加 UI（Xbox Bar）、降低 alt-tab 黑屏，但缺點就是： 看起來像無邊框， 還是會回報給 Unity 「正在使用 Exclusive Fullscreen」， 顯示卡 API 也會回報獨佔模式成功，所以 Unity 的 log 會印出 ExclusiveFullScreen，但你看到的是 Borderless 外觀。 需要處理【關閉全螢幕最佳化】 有兩種做法： 方法一、設定 exe 檔案，關閉全螢幕最佳化。 方法二、游戲啓動后新增注冊表關閉最佳化，如下： public static void SetDisableFullscreenOptimization() { string exePath = string.Empty; // 獲取當前執行檔路徑 (包含執行檔名稱) Debug.Log("System.AppDomain.CurrentDomain.BaseDirectory: " + AppDomain.CurrentDomain.BaseDirectory); // 獲取執行檔所在目錄的路徑 Debug.Log("Application.streamingAssetsPath: " + Application.streamingAssetsPath); // 獲取可讀寫的持久化資料夾路徑 Debug.Log("Application.persistentDataPath: " + Application.persistentDataPath); try { string baseDirectory = AppDomain.CurrentDomain.BaseDirectory; string exeName = Application.productName + ".exe"; exePath = Path.Combine(baseDirectory, exeName); } catch (System.Exception e) { Debug.LogError($"使用 BaseDirectory 獲取執行檔路徑時發生錯誤: {e.Message}"); return; } if (string.IsNullOrEmpty(exePath)) { Debug.LogError("無法推導執行檔路徑，操作取消。"); return; } // 檢查檔案是否存在（額外的安全檢查） if (!File.Exists(exePath)) { Debug.LogError($"推導的路徑找不到執行檔。路徑: {exePath}"); return; } // Windows 註冊表路徑 string registryPath = @"Software\Microsoft\Windows NT\CurrentVersion\AppCompatFlags\Layers"; try { using (RegistryKey key = Registry.CurrentUser.CreateSubKey(registryPath)) { if (key != null) { object existingValue = key.GetValue(exePath); // 我們要寫入的值是 "~ DISABLEDXMAXIMIZEDWINDOWEDMODE" const string compatibilityFlag = "~ DISABLEDXMAXIMIZEDWINDOWEDMODE"; if (existingValue == null || !existingValue.ToString().Contains("DISABLEDXMAXIMIZEDWINDOWEDMODE")) { key.SetValue(exePath, compatibilityFlag, RegistryValueKind.String); Debug.Log("已成功設定相容性：停用全螢幕最佳化。路徑: " + exePath); } else { Debug.Log("相容性設定已存在，無需重複設定。"); } } else { Debug.LogError("無法開啟或建立註冊表路徑：" + registryPath); } } } catch (System.Security.SecurityException e) { Debug.LogError($"設定註冊表失敗，可能是權限不足或其他系統錯誤: {e.Message}"); } catch (System.Exception e) { Debug.LogError($"設定註冊表時發生未知錯誤: {e.Message}"); } } 問題4、獨占模式切換無邊框模式時，解析度無效問題 這個情況比較特殊，操作的流程如下： 先設定游戲變成全屏幕 800x600，再設定 無邊框模式 1920x1080，爲什麽顯示出來的解析度竟然是 800x600 無邊框模式 Exclusive Fullscreen 模式會改變桌面解析度，而 Windows（FSO）不會在你切到無邊框時自動切回。最後的做法是將 exe 的全螢幕最佳化關閉。 另外再補上一個流程就是切換解析度時執行兩次，如下： Screen.SetResolution(width, height, fullScreenMode); await UniTask.DelayFrame(1); Screen.SetResolution(width, height, fullScreenMode); 問題5、獨占模式滑鼠可以超出游戲範圍 下面給了解決辦法，測試了其他方法無效，最後的做法是在切換到獨占模式時設定限制 Cursor 可移動範圍，詳細做法如下： void OnApplicationFocus(bool focus) { Cursor.visible = !focus; #if UNITY_STANDALONE_WIN && !UNITY_EDITOR PlatformUtils.ClipCursorToScreen(Screen.fullScreenMode == FullScreenMode.ExclusiveFullScreen); #endif } public static async UniTask<bool> SetupResolution(int solutionIdx, int fullMode) { //... await SetResolutionAsync(width, height, displayModeValue); PlatformUtils.ClipCursorToScreen(displayModeValue == FullScreenMode.ExclusiveFullScreen); } private static async UniTask SetResolutionAsync(int width, int height, FullScreenMode fullScreenMode) { Screen.SetResolution(width, height, fullScreenMode); await UniTask.DelayFrame(1); Screen.SetResolution(width, height, fullScreenMode); //加入超時處理，避免無限等待 //這裡設定1秒超時 int timeoutFrames = 60; // Assuming 60 frames per second int waitedFrames = 0; while (Screen.width != width || Screen.height != height) { await UniTask.Yield(); // 等下一幀 waitedFrames++; if (waitedFrames > timeoutFrames) { Log.Warning("設定解析度超時"); break; } } } public static void ClipCursorToScreen(bool clip) { if (clip) { RECT rect = new RECT { Left = 0, Top = 0, Right = Screen.width, Bottom = Screen.height }; ClipCursor(ref rect); } else { ClipCursor(System.IntPtr.Zero); } } 問題6、游戲在熒幕2游玩，設定全螢幕后因爲要鎖定滑鼠在游戲範圍内，結果滑鼠被鎖定在熒幕1 需要根據目前在那個熒幕獲取對應 Rect 做設定，不然就會出錯，修改 code 如下： public static RECT GetCurrentMonitorRect() { IntPtr hwnd = GetActiveWindow(); if (hwnd == IntPtr.Zero) throw new Exception("Cannot get Unity HWND"); IntPtr hMonitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST); if(hMonitor == IntPtr.Zero) throw new Exception("MonitorFromWindow failed"); MONITORINFO mi = new MONITORINFO(); mi.cbSize = Marshal.SizeOf(typeof(MONITORINFO)); if (!GetMonitorInfo(hMonitor, ref mi)) throw new Exception("GetMonitorInfo failed"); return mi.rcMonitor; } public static void ClipCursorToCurrentMonitor(bool enable) { if (!enable) { ClipCursor(IntPtr.Zero); return; } RECT rect = GetCurrentMonitorRect(); ClipCursor(ref rect); } 問題7、游戲啓動的解析度會莫名其妙被設定成其他的解析度 我的游戲，解析度保留在視窗化，切換到無邊框，直接 alt+F4 關閉游戲（沒有保存設定，應該啓動回事視窗話），啓動后會先是無邊框（啓動讀取設定根據設定顯示對應解析度），又切換成視窗化 但如果切換無邊框并保存設定都不會有問題，唯獨游戲中切換無邊框立即關閉游戲，啓動會是無邊框，爲什麽？ Unity 在切換全螢幕模式（含無邊框）時，會立即刷新一組“硬體層級解析度記錄” 這個狀態會被同步到： Windows 的 registry 裡，（不是你的 PlayerPrefs，而是引擎自己的） 因此： 你沒有保存遊戲內設定，但作業系統已經記住你最後使用的是無邊框模式，所以下次 Unity 在啟動時，就會以最後的「硬體顯示模式」啟動，而不是你的設定檔。 要修改這個問題，就是在套用解析度時就應該寫入設定檔，這也是爲什麽歐美FPS游戲的保存機制中，圖形的保存是修改當下就保存，而游戲功能選項卻是退回去才會提示保存。 問題8、在筆記本接外顯示器時切換全螢幕（獨占模式）直接卡死畫面但游戲流程還在執行 問題9、在4k顯示熒幕上出現周期性卡頓 可能原因分析 刷新率不匹配問題（最可能） 你的4K螢幕支援多種刷新率，但在無邊框模式下可能沒有正確選擇： 獨占模式：遊戲可以強制使用 60Hz，與你的目標FPS完美匹配 無邊框模式：可能被強制使用 120Hz/180Hz/240Hz，但遊戲只能輸出60FPS 結果：螢幕每秒刷新120次，但遊戲只更新60幀 → 卡頓感 或者VSync與高刷新率衝突 → 幀時間不穩定 2. 桌面合成器（Desktop Window Manager）開銷 無邊框模式下，Windows DWM會介入進行視窗合成 4K解析度的合成開銷很大，尤其是高刷新率時 3. VSync行為差異 獨占模式：VSync直接控制顯示器 無邊框模式：VSync通過DWM，可能有額外延遲或幀時間波動 問題10、4k 桌面解析度切换到無邊框或是視窗模式，開啓 vsync 后選擇 60hz 卡頓情況比不開 vsync 嚴重，不開vsync，fps會跑到130-140。 Windows DWM（Desktop Window Manager） 合成 + 嚴格 16.67ms 幀同步，一旦錯過就掉到 33ms（30fps）或產生節奏性卡頓 1）關閉vsync 2）fps不要卡 60 QualitySettings.vSyncCount = 0; Application.targetFrameRate = 58;
Steam 雲端存檔完全指南：查看、強制同步與徹底刪除教學
查看雲端存檔 輸入網址 https://store.steampowered.com/account/remotestorage 然後滾動到想查看的游戲，點擊顯示檔案 可以看到目前游戲下的所有雲端存檔 强制同步雲端存檔做法 使用 WIN+R 輸入指令開啓 steam 主控臺 steam://open/console 在主控臺輸入指令 cloud_sync_up 4778780 這代表强制同步雲端存檔 刪除所有雲端存檔方式 開啓 steam client 確認所有同步都跑完的情況下，進入主控台 這時候進入游戲安裝目錄還能看到 remotecache.vdf 是含有所有檔案的版本 C:\Program Files (x86)\Steam\userdata\31135361\4770000\remotecache.vdf 然後進入存檔路徑 C:\Users\joker0000\AppData\LocalLow\U**\ 將遠端存檔目錄全刪掉 確保刪除后，回到主控臺輸入同步指令 cloud_sync_up 4778780 這時候再回到雲端就會發現檔案都刪除了 https://store.steampowered.com/account/remotestorageapp/?appid=4770000
OSM 的隱藏陷阱：λ 指數修正 2 —— 建照數據校準與方法論局限
延續上一篇的話題，關於 λ 的修正計算，如何更準確的預估或是計算出 λ 呢？ 理論上 λ 應由「外部基準」決定，以下是三條可行的校準路徑： PATH 1：對照官方建照數據 最直接的方法——以政府公布的「年度核發建照數」作為 ground truth，反推 λ： λ* = log(N_t/N_0 / G_real) / log(C_t/C_0) 其中 G_real 為官方建照累計增長率。台中建管處（CPA）有公開資料，可據此反算出城市特定的 λ 值。 PATH 2：跨城市穩定性檢驗 若同一個 λ 值應用於台北、高雄、台中均能與官方數據對齊，則可視其為 OSM 系統性偏差的穩健估計，與個別城市無關。 PATH 3：保守採用 λ = 0.5 在缺乏 ground truth 校準的情況下，以 0.5 作為「未知條件下假設各佔一半」的保守選擇。報告中務必附上靈敏度分析表，讓讀者知悉結論在 λ ∈ [0.25, 0.75] 範圍內的穩健性。 批次修正多項指標 以下一鍵完成所有指標的偏差修正： # osm/bias_correction.py:146-179 def run_bias_correction(osm_metrics, target_cols=None, base_year=BASE_YEAR, lambda_=DEFAULT_LAMBDA): if target_cols is None: target_cols = ["building_count", "amenity_count", "shop_count", "poi_diversity"] community_proxy = compute_total_elements(osm_metrics) yearly_means = osm_metrics.groupby("year")[target_cols].mean() all_results = [] for col in target_cols: series = yearly_means[col].dropna() df = bias_correction_index(series, community_proxy, base_year, lambda_) df.insert(0, "indicator", col) all_results.append(df) return pd.concat(all_results, ignore_index=True) 輸出兩份結果檔： output/osm_bias_corrected.csv——各指標各年的原始與修正後增長指數 output/osm_bias_sensitivity.csv——λ 靈敏度分析結果 修正前後各 OSM 指標的 8 年總增長率對比（λ = 0.5）： 指標 原始增長 修正後增長 差距幅度 building_count +393% +140% 巨大 amenity_count +44% −30% 中等 shop_count +61% −22% 中等 poi_diversity +37% −33% 中等 為何 amenity / shop / poi_diversity 修正後呈現負增長？ 這個結果頗具啟示性——它說明：設施、商店、POI 多樣性的增長速度，低於社群整體活躍度的增長速度。以白話解讀： 社群志願者在 8 年間新增了 4.23 倍的「總元素」 其中建築物增長最為顯著（4.93 倍），佔新增貢獻的絕大部分 設施、商店類元素雖有增長，但比例上未能跟上社群整體活躍度的提升 修正後的負值意味著：這些指標的「真實增長」可能接近零，幾乎全數源於社群回填效應。 這一發現本身即具有獨立的研究價值——它告訴規劃者一個有悖直覺的事實：從 OSM 觀察到的「商店設施大量增加」，很可能並非真實現象。 八、方法論的局限 誠實面對這套修正方法的潛在問題： 1. 假設「真實建設增長」與「社群活躍度」相互獨立 此假設未必成立。當城市快速擴張時，社群往往也更有動力更新 OSM（例如建商人員主動補登自家樓盤）。兩者之間實際存在正相關。 即便如此，修正的方向仍是正確的——只是修正幅度可能略有過度。 2. C_t 以「總元素數」代理過於粗略 更精細的做法：以 OSM changeset 數量、活躍編輯者數，或某類「不會新增的歷史地物」（如山脈、河川的 way）作為更純粹的社群活躍度代理。但 changeset 資料須從 OSM Planet 下載，工程量較大。 3. λ 不宜跨指標共用 修正建築物所用的 λ，未必適用於設施類指標。理論上每個指標都應有其專屬 λ，但此舉會使校準工作量大幅增加。 4. 全域 λ 假設的侷限 目前 λ 假設在整座城市中均一。實際上市中心與郊區的「社群覆蓋率」差異顯著——市中心早已被 OSM 完整標記，郊區則在後期才陸續補登。空間異質性 λ(x, y) 是更嚴謹的作法，但需要更多前提假設。 九、本篇小結 概念 重點說明 問題 OSM 數據增長 = 真實建設 + 社群回填，兩者混雜難以區分 代理變數 C_t = 建築 + 設施 + 商店 + 休閒 總數，作為社群活躍度代理 修正公式 G_corr = (N_t/N_0) × (C_0/C_t)^λ λ 選擇 預設 0.5，並進行 λ ∈ [0, 1] 靈敏度分析 校準方式 以官方建照數據或跨城市穩定性反推真實 λ 意外發現 設施、商店類指標修正後增長為負，暗示真實增長接近零
三門問題的資訊熵解法：用 Shannon 熵計算 Monty Hall 換門的資訊增益，兼談與 Everett 多世界的類比
三門問題的資訊熵解釋 核心思路 三門問題（Monty Hall）的關鍵在於：主持人的動作不是隨機的，而是攜帶訊息的。用資訊熵來看，整個過程是一次「熵減少」的過程──你獲得了訊息，不確定性就下降了。 初始熵 三扇門，獎品等機率藏在其中一扇後面： $$ H_0 = -\sum_{i=1}^{3} \frac{1}{3}\log_2\frac{1}{3} = \log_2 3 \approx 1.58 \ bits $$ 當主持人揭示門後的熵 重點在於主持人知道獎品在哪，且他的選擇受約束（不能開你選的門，不能開有獎品的門）。所以他打開的門本身包含了訊息。 若你原選的門（假設 A）剛好是獎品門（機率 1/3），主持人在 B、C 中隨機開一扇，各 1/2。 若獎品在 B（機率 1/3），主持人被迫開 C。 若獎品在 C（機率 1/3），主持人被迫開 B。 主持人開門這個"訊號"傳遞了條件機率資訊。用貝葉斯更新後： $$ P(\text{獎品在B} \mid \text{主持人開C}) = \frac{2}{3}, \quad P(\text{獎品在A} \mid \text{主持人開C}) = \frac{1}{3} $$ 不確定性從 3 選 1（$\log_2 3 bits$）坍縮到 2 選 1、但機率不均（2/3 vs 1/3）： $$ H_1 = -\frac{1}{3}\log_2\frac{1}{3} - \frac{2}{3}\log_2\frac{2}{3} \approx 0.918 \ bits $$ 熵減少量： $H_0 - H_1 \approx 1.58 - 0.918 = 0.667 bits$ ——這正是主持人（那一步互動作的資訊）。 回到問題核心，為什麼"換門"是理性的 在資訊理論視角下，主持人開門不是噪聲，是一次非對稱的信息注入：他必須遵守規則（不能打開獎品門、不能開你選的門），這個約束讓他的"選擇"本身洩露了獎品位置的線索。換門利用了這個訊息，不換則等於把這份訊息白白丟棄，等價於仍停留在原始的 $\frac{1}{3}$ 機率上。 然而與 Everett 多世界進行類比對照時 這個類比有意思但要小心區分層次： 三門問題是經典貝葉斯推斷——熵的下降來自"觀測者獲得了新資訊"，本體上獎品位置從一開始就是確定的，只是你不知道。 Everett 的多世界詮釋中，量子態的"熵"（如馮諾依曼熵）在退相干後增加，是因為系統與環境糾纏，不同分支彼此退相干、變得"經典化"。這裡沒有一個先驗確定的結果被"揭示"——按 MWI，所有分支都實際發生了，你只是發現自己處於某一支。 一個常見的對比點是：三門問題的熵下降屬於認知熵（Shannon 熵，反映主觀資訊狀態的更新）；而量子退相干中的熵增屬於糾纏熵/馮諾依曼熵（von Neumann 熵），反映的是分支之間關聯性的物理增長，兩者數學形式相似但物理內涵不同——這也是很多人在類比量子貝葉斯分支時容易混淆的地方分支時容易混淆的地方。
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",
OSM 的隱藏陷阱 - 以 λ 指數修正志願者貢獻偏差
本篇要講述的是台中 OSM 建築數量 2018→2025 增長 392%，但其中有多少是真實建設、多少是社群回填？本篇提出一個可量化、可控制修正強度的 λ 偏差修正指數。 關於 OSM 其中的 392% 增長，有多少是真的？ 先來檢視台中市 OSM 數據 8 年的原始增長： 年份 OSM 建築物總數 設施 商店 休閒 2018 16,926 1,015 408 30 2019 12,771 1,207 466 32 2020 16,117 1,520 541 39 2021 18,500 1,599 534 47 2022 24,973 1,640 590 49 2023 41,055 1,762 647 57 2024 47,584 1,874 690 59 2025 50,208 1,921 705 61 採樣點 500m 範圍內的「平均建築數量」更為顯著——從 2018 年的 27.6 棟，增至 2025 年的 136 棟，+392.1%。 然而其中一個問題隨之而來： 台中市真的在 8 年內新建了比原有存量多 4 倍的建築嗎？ 答案顯然是否定的。即便是開發最密集的城市，10 年內新建建築數量也不會超過存量的 1 倍。這 392% 中，相當大一部分其實是「OSM 社群志願者持續將既有建築數位化補登」，也就是「歷史回填」（backfilling）效應。 量化「回填效應」：社群貢獻量代理變數 若某城市/地區 2018 年 OSM 共有 1 萬個元素（建築 + 設施 + 商店 + 休閒），2025 年增至 5 萬，則社群整體活躍度約提升 5 倍。建築數的增長，有相當一部分是被這個系統性的「社群更積極補登」推高的。 我們在這邊定義「社群貢獻量代理」C_t： C_t = building_count_t + amenity_count_t + shop_count_t + leisure_count_t 將所有有測量目標的標籤加總，作為志願者整體投入程度的代理變數。 # osm/bias_correction.py:37-54 def compute_total_elements(osm_metrics: pd.DataFrame) -> pd.Series: """ 計算每年 OSM 快照的「總元素數」（社群貢獻量代理）。 """ count_cols = [c for c in ["building_count", "amenity_count", "shop_count", "leisure_count"] if c in osm_metrics.columns] osm_metrics = osm_metrics.copy() osm_metrics["total_elements"] = osm_metrics[count_cols].sum(axis=1) return osm_metrics.groupby("year")["total_elements"].mean() 台中 8 年的 C_t（每採樣點 500m 範圍內均值）： 年份 C_t（總元素均值） 2018 34.6 2021 57.1 2025 146.4 C_2025 / C_2018 ≈ 4.23，社群貢獻量翻了逾四倍。 核心假設：社群貢獻量增長與真實建設增長是兩個獨立過程。OSM 元素總數的增長是兩者的疊加，而研究者真正關心的是後者。 修正公式 定義第 t 年的「校正後增長指數」： G_t_corr = (N_t / N_0) × (C_0 / C_t)^λ 各符號意義： 符號 意義 N_t / N_0 原始增長率（基準年 N_0 = 2018） (C_0 / C_t) 社群貢獻量的衰減倒數（C 愈大，此因子愈小） λ ∈ [0, 1] 修正強度，由分析者根據研究假設設定 三種直覺解讀： λ = 0：完全不修正 → G_corr = N_t / N_0，還原為原始指數 λ = 1：完全比例修正 → 假設所有 OSM 元素增長均來自社群活躍度提升 λ = 0.5：中度修正，假設增長中一半來自真實建設、一半來自社群回填 # osm/bias_correction.py:80-97 v0 = series[base_year] c0 = community_proxy[base_year] for year in sorted(series.index): v_t = series[year] c_t = community_proxy.get(year, c0) raw_growth = v_t / v0 if v0 > 0 else np.nan # 社群貢獻量修正因子 = (C_t0 / C_t)^λ if c0 > 0 and c_t > 0: correction_factor = (c0 / c_t) ** lambda_ else: correction_factor = 1.0 corrected_growth = raw_growth * correction_factor 將 λ 套用至台中數據 採用建議值 λ = 0.5： 年份 建築均值 原始增長 修正因子 (C_0/C_t)^0.5 修正後增長 2018 27.6 +0.0% 1.000 +0.0% 2019 31.0 +12.3% 0.943 +5.9% 2020 34.8 +26.1% 0.901 +13.6% 2021 47.6 +72.5% 0.778 +34.2% 2022 67.8 +145.7% 0.692 +70.0% 2023 109.8 +297.8% 0.582 +131.1% 2024 131.3 +375.7% 0.508 +141.7% 2025 136.0 +392.8% 0.486 +139.6% 392% 被壓縮至 140%，較接近一個快速發展城市的合理增速範圍。 原始指數顯示「8 年新增了 4 倍建築」，修正後顯示「8 年新增了 1.4 倍建築」——後者更符合人類認知與官方建照數據的數量級。 λ 靈敏度分析 λ 是無法從資料本身唯一決定的參數，因此靈敏度分析是不可省略的步驟——它能讓讀者清楚看見「不同假設下結論差距有多大」。 # osm/bias_correction.py:111-143 def sensitivity_analysis(series, community_proxy, base_year=BASE_YEAR, lambdas=None): if lambdas is None: lambdas = [0.0, 0.25, 0.5, 0.75, 1.0] final_year = sorted(series.index)[-1] rows = [] for lam in lambdas: df = bias_correction_index(series, community_proxy, base_year, lam) final_row = df[df["year"] == final_year].iloc[0] rows.append({ "lambda": lam, "corrected_growth_index": final_row["corrected_growth_index"], "implied_real_growth_pct": (final_row["corrected_growth_index"] - 1) * 100, }) return pd.DataFrame(rows) 台中 2018→2025 建築增長在不同 λ 值下的結果： λ 修正後增長 0.00 +393%（不修正） 0.25 +234% 0.50 +140%（中度修正，建議值） 0.75 +73% 1.00 +17%（完全比例修正） 只是在這情況下，下列兩種解讀都不合適： λ = 0 派：「OSM 增長即真實建設增長」→ 4 倍增長，呈現泡沫式擴張 λ = 1 派：「OSM 增長完全等比例反映社群活躍度」→ 17%，幾乎零成長 兩者皆不準確，真實情況必然介於兩者之間，靈敏度分析的價值在於：明確告知讀者「結論在 λ ∈ [0.25, 0.75] 範圍內均成立」，比直接拋出單一數字嚴謹得多。 後面我們將繼續講解關於 λ 值得其他參考計算方式，這本篇中著墨在城市 OSM 數據跟志願者貢獻偏之間的關係計算。
CodeGraph 實戰心得：用「地圖導航＋精確搜索」提升 Claude Code 程式碼理解效率
以下將 codegraph 納入實際項目運作查看使用效益 $ (py3.10) D:\OlgCase\RS\HXXR_3>codegraph init ┌ Initializing CodeGraph │ ◆ Initialized in D:\OlgCase\RS\HXXR_3 | | * Scanning files - 2,371 found | * Parsing code - done | * Resolving refs - done │ ◆ Indexed 2,371 files │ ● 69,568 nodes, 192,594 edges in 53.1s │ └ Done $ (py3.10) D:\OlgCase\RS\HXXR_3>codegraph status CodeGraph Status Project: D:\OlgCase\RS\HXXR_3 Index Statistics: Files: 2,371 Nodes: 69,568 Edges: 192,594 DB Size: 147.56 MB Backend: node:sqlite - built-in (full WAL) Journal: wal Nodes by Kind: method 25,000 field 16,000 import 8,000 enum_member 3,000 property 3,737 class 3,500 constant 2,515 file 2,365 namespace 1,581 struct 527 enum 421 variable 276 function 253 interface 207 Files by Language: csharp 2,285 python 42 xml 14 javascript 12 php 10 yaml 6 c 1 objc 1 [OK] Index is up to date 進入項目后提出需求給 cc cc先是透過llm分析出 keyword，然後查詢 codegraph 可用工具，再將關鍵詞輸入給codegraph "query": "UIAIDebugMenu AIDebugMenu aiwnd close hide AICondBlock refresh ai decision" 對話内容如下： 後續都是 Grep、Read ，如圖： codegraph 的實際使用流程并不是全面替代 grep、read，而是將項目 code roadmap 查找出來 先用 codegraph 做「地圖導航」 用戶提出需求 ↓ mcp__codegraph__codegraph_explore ← 只用一次 ↓ 目的：快速了解整個專案結構 "讓我先知道這個 codebase 長什麼樣" codegraph 的作用是給 Claude 一個高層次的程式碼地圖，知道哪些檔案、哪些 class、哪些 function 存在。 再來用 Grep + Read 做「精確搜索」 已知地圖後 ↓ Grep × 8 ← 搜尋具體關鍵字（如 "aiwnd", "CloseUI", 事件名稱） Read × 4 ← 讀取找到的具體檔案內容 後面因爲 Claude 已經知道方向了，就不需要再用 codegraph，改用更精準、更輕量的工具。 本質差異 工具 用途 比喻 codegraph 理解整體結構、依賴關係 看地圖 Grep 搜尋特定字串/pattern 用關鍵字查字典 Read 讀取完整檔案內容 打開具體頁面閱讀 沒有全部使用 codegraph 的原因大概也是如下： codegraph 執行成本 每次呼叫需要分析程式碼結構，在 CPU、IO 上比 grep 慢且耗資源。 必要性 第一次 codegraph 已給出足夠的「索引」，後續只需要針對性搜尋。 Claude 的工具選擇策略 不確定結構 → codegraph（掃描全局） 已知方向 → grep（精確定位） 找到目標 → read（深入閱讀） 如下是 Claude 的工作流程： codegraph（1次）→ 建立認知地圖 ↓ grep（8次）→ 精確定位關鍵程式碼 ↓ read（4次）→ 深入理解細節
Shannon 熵（標準定義） $$ H(X) \;=\; -\sum_i P_i \log P_i \;\ge\; 0 $$ $H$ 代表的是 Entropy 不確定性 / 亂度；分布越平均，$H$ 越大（越不確定）；分布越尖（越確定），$H$ 越小。 均勻分布（n 個等機率）達最大 $H = \log n$；確定事件 $H = 0$。 在上述説到分布越平均，熵越大越不確定，大概可以思考為篩子6個面每一個面的概率都為 $\frac{1}{6}$，這代表相對的不確定性，因爲你無法明確的説出接下來會篩中1或3， $熵 \approx 0$：但如果颱風來了，那麽大概率你會說今天會下雨，這就是確定性高 $熵 \gt 0$：篩子6面 Everett 的「資訊」 Everett 不用 $H$，他定義資訊（information）為： $$ I_X \;=\; \sum_i P_i \log P_i \;=\; -\,H(X) $$ ⚠️ 注意正負號：Everett 的 $I_X$ 就是 負熵（negative entropy）。 Shannon $H$ Everett $I$ 公式 $-\sum P\log P$ $+\sum P\log P$ 意義 不確定性 「銳利度 / 已知程度」 分布變尖時 變小 變大 確定事件 $0$（最小） $0$（最大，因 $P\log P\to0$） 原本的shannon熵在不確定性大時 H 值增加，而確定的時候 H 值為 0 另外一個解釋方式則是 Everett 想要的「觀測之後會增加」的量。理由是觀測讓分布變尖 → 不確定性 $H$ 下降 → 「資訊」$I$ 上升。用 $I$ 講「觀測獲得資訊」在符號上更順。 §4 互資訊 = 關聯（全章的主角） 兩個變數綁多緊？Everett 定義 X 與 Y 的關聯（correlation）： $$ \{X,Y\} \;=\; \sum_{i,j} P(x_i,y_j)\,\log\frac{P(x_i,y_j)}{P(x_i)\,P(y_j)} $$ 這正是現代資訊論的互資訊（mutual information） $I(X;Y)$。逐項拆解： 若 X、Y 獨立：$P(x,y)=P(x)P(y)$ → 每項 $\log 1 = 0$ → $\{X,Y\}=0$。沒關聯。 若 X、Y 有關聯：聯合機率偏離「獨立預測」→ $\{X,Y\}>0$。 證明「互資訊 I(X;Y) 一定 ≥ 0」 看這張圖:藍色曲線是 y = log t(自然對數,凹函數),橘色直線是 y = t − 1(在 t=1 處的切線)。因為 log 是凹函數,它的切線永遠在它自己上方——這條直線只在切點 t=1 碰到曲線,其他地方都比曲線高。這就是整段推導唯一用到的「物理直覺」。 1. 互資訊的定義 $I(X;Y) = \sum p(x,y) \log\frac{p(x,y)}{p(x)p(y)}$ 令 $p = p(x,y)(真實聯合分布)$、$q = p(x)p(y)$(假裝獨立時「應該」長怎樣)。於是 $I(X;Y) = \sum p \log\frac{p}{q}$ 這就是 p 對 q 的 KL 散度。 2. 套用圖上的不等式 圖上畫的是 log t ≤ t − 1。現在把 t 換成 q/p(注意,是反過來的比值): $log\frac{q}{p} \le \frac{q}{p} - 1$ 3. 兩邊同乘 p,再對所有 (x,y) 求和 因為 p ≥ 0,不等式方向不變: $p\log\frac{q}{p} \le q - p$ 對所有 (x,y) 加總: $\sum p\log\frac{q}{p} \le \sum q - \sum p$ 4. 右邊為什麼是 0 $\sum q(x,y) = \sum p(x)p(y) = \left(\sum p(x)\right)\left(\sum p(y)\right) = 1\times 1 = 1$ $\sum p(x,y) = 1 \quad \text{(本來就是一個機率分布)}$ 所以右邊 = 1 − 1 = 0。 5. 左邊等於什麼 $\sum p\log\frac{q}{p} = -\sum p\log\frac{p}{q} = -I(X;Y)$ 6. 合起來 $-I(X;Y) \le 0 \;\Longrightarrow\; I(X;Y) \ge 0$ 而且,因為 log t = t − 1 唯一在 t = 1 成立(就是圖上那個相切點),所以 I(X;Y) = 0 的充要條件是「對所有 (x,y) 都有 q/p = 1」,也就是 p(x,y) = p(x)p(y) 處處成立——也就是 X、Y 獨立。 所以整個證明的邏輯鏈是: 凹函數的切線不等式(圖中那條線)→ 代入 q/p → 求和後右邊自動消成 0 → 左邊剛好是 −I(X;Y) → 得到 I ≥ 0。 Everett 在 Appendix I 就是用「對數是凹函數」的凸性不等式證這件事（原文「Convex function inequalities」「Refinement theorems」）。這就是深度 3 要你看到的：關聯非負，不是約定，是定理。