↓

LLMAgent:在本地 7B 模型上做多智能體助理(LangGraph + Ollama + RAG)

··
資訊科技應用 AI 應用 LLM Agent LangGraph RAG Ollama

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 不變量
#

下列規則在任何實作中都應成立:

  1. 路由優先級是 team > code > file > task。 規則命中就不呼叫 LLM;規則沒命中才交給 LLM 做 JSON 分類;LLM 失敗或逾時時,預設為 general。
  2. 所有對 Ollama 的呼叫都有逾時,預設 60 秒,可由環境變數調整。
  3. 傳給模型的上下文只取最近 6 則訊息;完整歷史由 LangGraph 的 MemorySaver 依 thread_id 保存,僅在程序存活期間有效。
  4. 任務有上限與重試上限。 子任務最多 5 個;每個任務最多重試 2 次(含初次共 3 次嘗試),超過標為失敗。
  5. 程式執行在子程序中進行,逾時預設 10 秒;失敗後把錯誤回饋給 LLM 修正,最多重試 2 次,仍失敗就改用文字回答。
  6. 每次程式執行使用獨立的 git worktree,結束後在 finally 清理;建立動作是冪等的。
  7. RAG 有降級路徑:索引不存在改用 grep;CUDA 不可用時 Embedding 退回 CPU;Reranker 載入失敗後不再重試。
  8. 索引是增量的:以檔案的 SHA-1 判斷,未變更的檔案跳過,force 才全部重建。
  9. 輸出語言:任務結果英文占比超過 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 查詢流程:

  1. Query Rewriting:用 Ollama 展開指代與模糊的查詢
  2. 向量召回 top_k × 3(預設 top_k = 6)
  3. Cross-Encoder 重排,取回 top_k 筆
  4. 組合 Context,總字元上限 6000
  5. 距離閾值 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 啟動與驗收順序
#

  1. 安裝依賴並設定 .env(bash setup.sh 或用 uv 建環境)。
  2. ollama pull qwen2.5:7b-instruct,用 ollama list 確認。
  3. 對要查詢的資料夾建索引:.venv/bin/python RAG/main.py index <資料夾>。
  4. 啟動介面:chainlit run src/app.py -w,確認出現歡迎訊息。
  5. 依 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 條件」,改動後能逐項重跑,這也是本文把驗收條件放在最前面的原因。