前幾天把 Codex App Server 接起來後,我開始遇到一個比 Tool 更實際的問題。
假設我要做的是:
LINE
↓
FastAPI
↓
Codex App Server
↓
Codex Agent
這個 Agent 不是跑完一次就消失,而是會持續跟使用者工作。
例如某天 Codex 說:
我可以把 Jira 的登入 Token 記起來,下次就不用重新登入。
我糾正它:
Credential 不可以放進 Agent Memory,登入資訊要由外層系統管理。
隔幾天我重新開一個 Thread,又問:
上次 LINE Agent 的登入方式最後怎麼設計?
這時我真正想知道的不是「Codex 有沒有 Memory」。
而是三件很實際的事情:
1. 我要在哪裡把 Memory 打開、保留多久?
2. Codex 怎麼決定剛才那句糾正值得記?
我能不能自己改規則?
3. 如果同一個 Agent 給三個人使用,
三個人的 Memory 怎麼真的分開?
研究完目前的 openai/codex repo 後,我覺得理解 Codex Memory 最簡單的方法,是先把它分成三層:
| 層 | 負責什麼 | 我能不能直接改 |
|---|---|---|
config.toml |
開關、保留時間、模型、污染控制 | 可以 |
| Codex Memory Prompt | 什麼值得學、怎麼整理、怎麼讀回來 | 不能用一般 config 改 |
| Application Layer | User、Team、Memory ownership、自訂 Human Gate | 自己實作 |
這三層搞清楚,後面的設計就簡單很多。
假設現在只有我一個人使用 LINE Agent。
先不要碰多人,也不要先設計新的 Memory DB。
第一件事就是把 Codex 原生 Memory 正確打開。
$CODEX_HOME/config.tomlCodex 的 Memory feature 目前在 feature registry 中已經是 Stable,但預設沒有打開。
所以部署 Agent 時,我不會依賴預設值,而是直接在:
$CODEX_HOME/config.toml
寫清楚:
[features]
memories = true
[memories]
generate_memories = true
use_memories = true
dedicated_tools = true
max_rollout_age_days = 30
min_rollout_idle_hours = 12
max_rollouts_per_startup = 4
max_raw_memories_for_consolidation = 512
max_unused_days = 180
disable_on_external_context = true
第一組是最重要的:
generate_memories = true
use_memories = true
generate_memories 決定:
新的 Thread 要不要被拿去產生 Memory。
use_memories 決定:
新的 Thread 要不要使用以前留下來的 Memory。
所以也可以做出:
generate=true / use=true
→ 正常學習,也正常讀 Memory
generate=false / use=true
→ 不再學新的,但可以使用以前的 Memory
generate=true / use=false
→ 現在不讓舊 Memory 影響 Agent,
但這次工作仍可以成為未來的 Memory
第二組是 Memory 的生命週期:
max_rollout_age_days = 30
max_unused_days = 180
max_rollout_age_days 是:
多舊的 Thread 還值得第一次拿來學?
Codex預設是 10 天。
如果今天才啟動 Memory,一個半年前、從來沒被整理過的 Thread,通常不會突然被拿去產生新的 Memory。
max_unused_days 則不同,它控制的是:
已經形成的 Memory,多久沒有再被使用後,不再進入 active consolidation?
預設是 30 天。
如果拿 Codex 做 coding assistant,30 天可能夠。
第三組是執行成本:
min_rollout_idle_hours = 12
max_rollouts_per_startup = 4
max_raw_memories_for_consolidation = 512
這是在限制 Codex每次背景整理多少資料,不是 Memory分類規則。
現在回到剛才的例子。
User 說:
Credential 不可以存在 Agent Memory。
Codex不會因為看到「不可以」三個字就寫進資料庫。
它會等 Thread 符合條件後,進入 Memory pipeline:
Thread
↓
Phase 1
↓
SQLite
↓
Phase 2
↓
MEMORY.md
Phase 1 與 Phase 2 不需要我們手動呼叫。
它們是 Codex Memory subsystem 自己處理的背景流程。
真正決定「什麼叫值得記?」的是這個檔案:
codex-rs/memories/write/templates/memories/
stage_one_system.md
也就是這份 Markdown 不是讓使用者修改的設定檔。
它是 Codex source code 的一部分,build 時直接被包進 binary。
這份 Prompt 要 Memory Writer 特別注意:
User preference
User correction
Repeated request
Failure
Reusable procedure
Environment / workflow knowledge
例如:
Codex:
可以記住 Jira Token。
User:
不要把 Credential 放進 Memory。
...
Codex:
OneDrive Token 也可以記住。
User:
我前面已經說過,
所有 Credential 都不能進 Memory。
第二次糾正就比一次性的討論更有價值。
Memory Writer可能抽出:
User repeatedly corrected the agent
that external credentials must not be
stored in Agent memory.
Future behavior:
keep credentials in the hosting application.
這就是 Codex目前的 Auto-learning。
它不是 fine-tune 模型。
而是:
User experience
↓
Persistent Memory
↓
影響未來 Thread
假設 Phase 1 已經產生:
Thread A
→ Credential 不進 Memory
Thread B
→ LINE 透過 FastAPI 接 App Server
Thread C
→ Docker 不可以依賴 sudo
Thread D
→ 再次確認 Credential 規則
這些候選 Memory 先存在 state DB。
Phase 2 才會把它們整理成長期 Memory。
它使用:
codex-rs/memories/write/templates/memories/
consolidation.md
負責:
合併重複內容
處理衝突
淘汰 stale evidence
重新分類
必要時建立 Skill
最後主要產生:
$CODEX_HOME/memories/
├── memory_summary.md
├── MEMORY.md
├── raw_memories.md
├── rollout_summaries/
└── skills/
這也是另一個目前不能直接透過 config 改的地方。
這部分反而不需要自己做。
Codex原生就有類似 Memory Index 的設計:
memory_summary.md
它不是完整的 Memory,而是幫 Agent 判斷「現在這個 Query 有沒有可能需要歷史記憶?」
例如裡面有:
### LINE Agent
keywords:
line, webhook, fastapi,
codex app server, credential
description:
LINE Agent 架構與登入相關決策。
User問:
之前 LINE 登入最後怎麼設計?
流程大致是:
User Query
↓
memory_summary.md
看到 LINE / credential
↓
search MEMORY.md
↓
找到相關 Task Group
↓
真的需要細節時
才讀 rollout_summaries/
讀取規則就在:
codex-rs/ext/memories/templates/memories/
read_path.md
Memory不是只進不出。
Codex Phase 2 selection 會看:
usage_count
last_usage
source_updated_at
長時間沒再被使用的 Memory,超過:
max_unused_days = 180
後就不再是 active Phase 2 input。
接下來 consolidation 可以根據 workspace diff 把失去有效 evidence 的內容清掉。
所以 max_unused_days 可以理解成:
Memory 的活躍保留窗口。
另一個實用設定是:
[memories]
disable_on_external_context = true
它用來避免 Web Search、MCP 等外部資料直接變成長期 Memory。
這個判斷不是 LLM 做的,而是 Codex runtime 的程式規則。
例如同一個 Codex 有三個 Thread:
Thread A → 修改本地程式
Thread B → Web Search 查 LINE API
Thread C → 解釋本地程式
Codex看到 Thread B 出現 WebSearchCall 這類 external context 時,會取得目前的 thread_id:
state_db::mark_thread_memory_mode_polluted(
...,
sess.thread_id,
...
)
.await;
所以最後是:
Thread A → enabled
Thread B → polluted
Thread C → enabled
不是整個 Codex 都被污染,只有使用 external context 的 Thread B。
Phase 1 挑選可學習的 Thread 時,又會直接限制:
WHERE threads.memory_mode = 'enabled'
因此:
Web Search / MCP
↓
Runtime 程式規則
↓
目前 Thread → polluted
↓
不進 Phase 1 自動學習
這裡可以把它理解成兩層:
Thread 能不能被學
→ Runtime 程式判斷
Thread 裡什麼值得記
→ Phase 1 的 LLM 判斷
到這裡其實可以做一個很明確的選擇。
如果只需要:
Codex自己從工作中學 Preference、Failure、Workflow。
直接使用原生 Memory。
如果想要:
某些重要資訊一定要 User 確認才准寫。
則不要去改 Phase 1。
Codex現在已經有:
add_ad_hoc_note
以及:
extensions/ad_hoc/notes/
這條 Explicit Memory 路徑。
因此可以加一個自己的:
.agents/skills/
└── memory-manager/
└── SKILL.md
內容只負責:
發現重要 Decision
↓
提出建議分類
↓
詢問 User
↓
User Confirm
↓
add_ad_hoc_note
例如:
Agent:
這筆內容建議記成長期 Memory:
「Credential 不進 Agent Memory」
分類建議:Security
是否確認?
User確認後才寫入 ad-hoc note。
如果還擔心模型跳過確認,可以讓真正的 Write Tool要求:
def write_memory(
category: str,
content: str,
confirmed: bool,
):
if not confirmed:
raise ValueError(
"user confirmation required"
)
...
這就是:
Skill
→ 規定 Agent 應該怎麼做
Tool
→ 真正阻止未確認寫入
因為目前 Codex沒有提供一份類似:
MEMORY_RULES.md
讓 Agent Developer 任意重寫 Phase 1 / Phase 2 taxonomy。
這是目前和 Claude Code Skill / Hook 型配置很不一樣的地方。
接著把 LINE Agent 從一個人擴成:
Vivian
Peter
Amy
最容易犯的錯是:
Vivian → Thread A
Peter → Thread B
Amy → Thread C
然後認為這樣就隔離完成。
Thread確實能隔離 conversation。
但 Codex Memory 還有另一個更重要的 boundary:CODEX_HOME
Codex source code 寫得很清楚:
codex-rs/core/src/config/mod.rs
/// specified by the `CODEX_HOME` environment variable.
/// If not set, defaults to `~/.codex`.
pub fn find_codex_home() ...
而 Memory、config、state 等資料都會以這個 home 為重要根目錄。
SQLite如果沒有另外設定:
sqlite_home
CODEX_SQLITE_HOME
也會 fallback 到:
CODEX_HOME
所以多人 Agent 最簡單、也最容易驗證的做法是:
一個 User 一個 CODEX_HOME。
例如 Vivian:
mkdir -p /data/codex/users/vivian
把前面的:
config.toml
放到:
/data/codex/users/vivian/config.toml
然後:
CODEX_HOME=/data/codex/users/vivian \
codex app-server
這個 App Server process 的 Memory 就會落在自己的 home。
概念上變成:
/data/codex/users/vivian/
├── config.toml
├── memories/
├── state DB
└── other Codex state
現在 Peter 傳 LINE 訊息進來。
FastAPI不是把 Peter 丟進 Vivian 的 App Server Thread。
而是建立:
/data/codex/users/peter
然後用這個 CODEX_HOME 啟另一個 App Server process。
結果就真的會變成:
/data/codex/users/
├── U001/
│ ├── config.toml
│ └── memories/
│
├── U002/
│ ├── config.toml
│ └── memories/
│
└── U003/
├── config.toml
└── memories/
這就是前面一直提到的:
state 分開。
不是概念上的「建立不同 User」。
而是真的讓三個 Codex process 使用三個不同的 state root。
因為共用的是 Agent Definition,不是 Runtime State。
例如所有人都使用:
/opt/line-agent/
├── AGENTS.md
├── .agents/
│ └── skills/
└── shared-config/
└── config.toml
建立新的 User Runtime 時,只把同一份 config.toml seed 進不同 home。
所以三個人:
共用:
Codex version
AGENTS.md
Skills
Tool definitions
Agent behavior
分開:
Thread
SQLite state
Memory
User-specific config/state
這才是:
1 logical Agent
:
N isolated users
這時不用另外發明架構。
只要改 tenant key。
私人聊天室:
tenant_id = source["userId"]
群組聊天室:
tenant_id = source["groupId"]
例如:
def get_tenant_id(event):
source = event["source"]
if source["type"] == "group":
return (
"group-"
+ source["groupId"]
)
return (
"user-"
+ source["userId"]
)
那:
Vivian private chat
→ /data/codex/users/U001
Peter private chat
→ /data/codex/users/U002
Project LINE Group
→ /data/codex/groups/G001
G001 裡三個人共享同一份 Codex Memory。
這是刻意的 shared memory,而不是意外污染。
例如希望:
Project 決策
→ Vivian / Peter / Amy 共用
Vivian preference
→ 只有 Vivian
Peter preference
→ 只有 Peter
Codex目前原生 Memory 沒有:
tenant_id
user_id
project_id
memory_scope
這種完整 hierarchy。