LLMAgent 是兩人合作的課程專案:跑在本機的日常助理,可以查檔案、排行事曆、上網搜尋,也能把大目標拆成多個子任務逐步完成,或寫程式計算後回答。限制是不能用雲端付費 API,模型要在一般筆電等級的機器上跑得動。
本文先講怎麼驗收,再講規則與決策,最後是設定與程式碼。涵蓋作者負責的部分,隊友負責的部分另行標明。原始碼目前是私有倉庫,文中不附連結。
Part 1:限制與架構 #
- 硬體為 8 到 16GB RAM、沒有高階 GPU(開發機為 RTX 3060 12GB)
- 不使用雲端付費 API
- 模型:Ollama 上的量化
qwen2.5:7b-instruct - 流程:LangGraph 狀態機,Router-Worker 模式
router → file / calendar / search / general → summarizer → END
router → task_planner → task_executor ──(loop)──┐
↑ │
└──────────────────┘
↓ (沒有待辦任務)
summarizer → task_translate → END
| 部分 | 負責人 |
|---|---|
| File Node、RAG、狀態持久化 | 作者 |
| 任務系統(Task Planner / Executor) | 作者 |
| Subagent、SkillLoader、Agent Teams | 作者 |
| Git Worktree 隔離、LM Studio 備援 | 作者 |
| Chainlit 介面、Router、Summarizer | 隊友 |
| Search、Calendar、Code 節點與沙箱 | 隊友 |
| 桌面 App 打包、UI 驗收表 | 隊友 |
7B 的小模型單次呼叫能處理的複雜度有限,所以整體做法是:每個節點只做一件小事,並在模型外面加上規則與保護。
Part 2:復刻規格 #
2.1 驗收條件 #
下表依專案的 UI 驗收表(由隊友整理,模型為 qwen2.5:7b-instruct)改寫。完成後應能通過:
| 操作 | 預期 |
|---|---|
| 一般問候 | 意圖判定為 general,全程繁體中文,不經過搜尋、檔案、任務節點 |
| 問最近的新聞 | 意圖為 search,先由 LLM 精煉關鍵字,至少出現 1 筆搜尋結果,最終輸出非空白 |
| 問專案內文件的內容 | 意圖為 file,顯示檔案來源與信心度,回答引用專案內容 |
| 同上,但向量索引不存在 | 自動改用 grep 備援,仍有回答 |
| 輸入含 2 項以上的列點,或命中任務關鍵詞 | Router 顯示「規則偵測」而不呼叫 LLM;拆成至少 2 個子任務,無人介入跑完,產出完整報告 |
| 子任務執行失敗 | 自動重試;單一子任務失敗不會讓整體崩潰 |
| 連續多輪對話 | 後面的訊息能回憶前面輸入的內容 |
| 任務結果的英文占比超過 30% | 最終顯示的是繁體中文 |
| 「深度分析」類需求 | 進入 Team:Coordinator、Researcher、Writer、Reviewer 依序執行,輸出有 Markdown 標題 |
| 要求用 Python 計算 | 回傳正確數值;程式出錯時顯示錯誤與重試紀錄;超時顯示超時錯誤;最終都有有意義的文字回答 |
程式嘗試匯入 subprocess、socket 等 |
被沙箱封鎖並回報原因 |
| 缺少 Google 憑證檔就問行程 | 顯示友善訊息,不出現 Python 例外 |
2.2 不變量 #
下列規則在任何實作中都應成立:
- 路由優先級是 team > code > file > task。 規則命中就不呼叫 LLM;規則沒命中才交給 LLM 做 JSON 分類;LLM 失敗或逾時時,預設為 general。
- 所有對 Ollama 的呼叫都有逾時,預設 60 秒,可由環境變數調整。
- 傳給模型的上下文只取最近 6 則訊息;完整歷史由 LangGraph 的
MemorySaver依thread_id保存,僅在程序存活期間有效。 - 任務有上限與重試上限。 子任務最多 5 個;每個任務最多重試 2 次(含初次共 3 次嘗試),超過標為失敗。
- 程式執行在子程序中進行,逾時預設 10 秒;失敗後把錯誤回饋給 LLM 修正,最多重試 2 次,仍失敗就改用文字回答。
- 每次程式執行使用獨立的 git worktree,結束後在
finally清理;建立動作是冪等的。 - RAG 有降級路徑:索引不存在改用 grep;CUDA 不可用時 Embedding 退回 CPU;Reranker 載入失敗後不再重試。
- 索引是增量的:以檔案的 SHA-1 判斷,未變更的檔案跳過,
force才全部重建。 - 輸出語言:任務結果英文占比超過 30% 時,多走一個節點翻成繁體中文。
2.3 決策與理由 #
| 決策 | 理由 |
|---|---|
| Router 先用規則,再用 LLM | 規則命中時不需要呼叫模型,省下一次呼叫與逾時風險 |
| 子任務上限 5 個 | 避免 LLM 一次規劃出過多任務 |
| 重試上限 2 次 | 失敗任務不會無限循環 |
| 依檔案類型切塊 | 保留語意完整性:Markdown 用標題、Python 用 AST、其他程式碼用 Regex 找定義、PDF 與純文字依段落,滑動視窗只是最後防線 |
| Embedding 直接在 CUDA 上跑,不經 Ollama HTTP | 程式註解記錄比 HTTP 方式快約 3 到 5 倍,這是粗估,不是 benchmark |
召回 top_k × 3 再用 Cross-Encoder 重排 |
向量搜尋快但粗糙;Cross-Encoder 準度高但成本高,只對候選執行 |
| Reranker 依硬體自適應 | 有 CUDA 用 bge-reranker-v2-m3,否則用 bge-reranker-base |
| Skill 以 Duck Typing 載入 | 只需 name、keywords、execute 三個屬性,不必繼承基底類別,新增能力不改核心流程 |
| 程式執行放進獨立 worktree | 不污染主工作目錄,平行任務不互相干擾 |
| 翻譯節點 | 小模型有時會在該用中文的地方輸出英文 |
Planner 不使用 format="json",改用容錯解析 |
文件只記錄做法,未記原因;觀察到 Router 輸出是短標籤,Planner 輸出較長較自由,兩者格式需求不同 |
2.4 元件與流程 #
RAG 查詢流程:
- Query Rewriting:用 Ollama 展開指代與模糊的查詢
- 向量召回
top_k × 3(預設top_k = 6) - Cross-Encoder 重排,取回
top_k筆 - 組合 Context,總字元上限 6000
- 距離閾值 0.75;處理分數為 NaN 的情況;啟動時預先載入模型
切塊參數: 預設 600 字元、重疊 80 字元。
任務系統: TaskBoard 管理狀態 pending → in_progress → completed / failed。Planner 把目標拆成子任務,Executor 認領下一個 pending 任務執行,失敗則退回 pending 重試。
Agent Team: Coordinator 拆解研究問題,Researcher 逐題回答,Writer 整合成報告,Reviewer 審查後 Writer 修訂一次。角色之間傳遞三種結構化訊息:dispatch、result、error,累積在狀態中方便除錯。
Skill: 目前 5 個:網路搜尋、檔案 RAG、一般對話、行事曆、LM Studio。也支援 Markdown 格式(YAML frontmatter 加內文,內文當 System Prompt),格式與 Claude Code 的 Skill 相容。
備援: Ollama 無法使用時可切到 LM Studio 的 OpenAI 相容 API,位址由環境變數設定。
2.5 平台設定與已知的坑 #
環境:
- Python 3.11 以上,用
uv管理;PyTorch 在 Linux 釘選torch==2.6.0+cu124,其他平台用通用版本。 - 主要依賴:
langgraph、langchain-ollama、ollama、chromadb、sentence-transformers、chainlit。 - 先在 Ollama 拉模型:
ollama pull qwen2.5:7b-instruct。
環境變數(放 .env,已加入 .gitignore,不進版控):OLLAMA_BASE_URL、DEFAULT_MODEL、LLM_TIMEOUT、CONTEXT_KEEP_LAST、CODE_EXEC_TIMEOUT、CODE_MAX_RETRY;選用的有 TAVILY_API_KEY、Google 憑證路徑、LMSTUDIO_*。
坑與對策:
nomic-embed-text-v1.5的載入條件。 需要trust_remote_code=True、依賴einops,而且建索引與查詢要使用不同的 task prefix(search_document、search_query)。載入失敗時程式會切換到BAAI/bge-m3。- Embedding 批次大小。 12GB 顯存同時放 Embedding 與 Reranker 時,批次設 128。
- 先建索引才能用檔案查詢。 向量庫放在
chroma_db/(本機產生,不進版控);沒有索引時只會走 grep 備援。 - Worktree 需要在 git 倉庫內執行。 實作是在專案根目錄執行
git worktree add --detach,目錄放在/tmp/llmagent-wt/;不在 git 倉庫內會直接報錯。 - Router 用
format="json",Planner 不用。 兩者的輸出解析策略不同,不要統一。 - 對話記憶不跨重啟。
MemorySaver存在記憶體中。
2.6 關鍵程式碼 #
以下為簡化後的概念版本。
路由規則(優先級與列點偵測):
def _rule_based_task_detect(query: str) -> str | None:
# 優先級:team > code > file > task
if _TEAM_KEYWORD_RE.search(query): return "team"
if _CODE_KEYWORD_RE.search(query): return "code"
if _FILE_KEYWORD_RE.search(query): return "file"
if _TASK_KEYWORD_RE.search(query): return "task"
if len(_NUMBERED_LIST_RE.findall(query)) >= 2: return "task" # 1. 2.
if len(_BULLET_LIST_RE.findall(query)) >= 2: return "task" # - / *
return None # 規則未命中 → 交給 LLM(format="json"),例外時預設 general
任務失敗的狀態轉換:
MAX_RETRY = 2
def fail(self, task_id, error):
task.retry_count += 1
task.status = PENDING if task.retry_count <= self.MAX_RETRY else FAILED
# 第 1、2 次失敗退回 pending;第 3 次失敗標為 failed
RAG 檢索(召回、重排、組合):
fetch_k = min(top_k * 3, collection.count())
result = collection.query(query_embeddings=[embed_query(q)], n_results=fetch_k, ...)
chunks = rerank(query, chunks, top_n=top_k) # Cross-Encoder,CUDA → v2-m3,否則 base
# 組合 Context 時總字元數上限 6000;沒有索引時拋 IndexNotFoundError → grep 備援
程式執行的沙箱前置碼(隊友負責):
_BLOCKED = {"subprocess","socket","requests","httpx","urllib","urllib3",
"ftplib","smtplib","telnetlib","paramiko","fabric","importlib"}
# 覆寫 builtins.__import__,名稱的第一段在 _BLOCKED 內就拋 ImportError
# 並把 os.system / popen / exec* / fork / spawn* / remove / unlink 換成會拋 PermissionError 的函式
2.7 啟動與驗收順序 #
- 安裝依賴並設定
.env(bash setup.sh或用uv建環境)。 ollama pull qwen2.5:7b-instruct,用ollama list確認。- 對要查詢的資料夾建索引:
.venv/bin/python RAG/main.py index <資料夾>。 - 啟動介面:
chainlit run src/app.py -w,確認出現歡迎訊息。 - 依 2.1 的表逐項驗收。
Part 3:實測、限制與心得 #
實測 #
| 測試 | 設定 | 結果 |
|---|---|---|
| Router 的 JSON 輸出 | qwen2.5:7b-instruct,JSON 模式,20 筆請求 |
20 筆全部是合法 JSON |
| 生成速度 | llama3:8b,10 輪 |
平均延遲 1.44 秒,約 63.8 token/s |
兩組都是小樣本:20 筆只能說明 JSON 模式在意圖分類上夠穩,不能外推;速度測試只量了短輸出;qwen2.5 的速度測試只跑了 2 輪,不作為結論。這些是第一階段的評估工作,由作者負責,包含確認 Ollama 的 GPU 加速、評估兩個模型的資源佔用與 JSON 穩定度,以及撰寫 token/s 測速腳本。專案最後的預設模型是 Qwen-2.5 7B。進度報告(6 月 8 日)記錄全專案測試 63 項通過、1 項略過。
已知問題 #
- 沙箱不是安全隔離。 實作是覆寫
__import__與部分os函式,只能擋常見的誤用;需要真正隔離時,應改用容器或受限的程序權限。 - 沙箱封鎖後仍會重試。 模型被封鎖後仍重試到上限(共 3 次)才失敗,缺少提早放棄的判斷。
- 行事曆只支援新增、刪除、查詢,修改行程在設計中但尚未實作(隊友負責)。
- 打包後的桌面 App 只能在建置它的環境執行,因為內含虛擬環境的 Python 直譯器是指向系統的符號連結(隊友負責)。
- 驗收表的 TC10 設計不夠嚴謹,找不到檔案也會被算作通過。
心得 #
- 能用確定性邏輯處理的,就不把判斷交給模型。 Router 先用規則,Planner 輸出有多層容錯解析,所有模型呼叫都有逾時,程式執行放進隔離的 worktree。在 7B 模型上,這些外圍的規則與保護,和模型本身一樣重要。
- 降級路徑要事先設計。 RAG 有 grep 備援、Embedding 有 CPU 退路、Reranker 失敗後不重試,任何一層失效都不會讓整個查詢失敗。
- 驗收表比功能清單更有用。 專案把每個節點的預期寫成「操作、預期、Pass 條件」,改動後能逐項重跑,這也是本文把驗收條件放在最前面的原因。