前幾天我們建立好專案過濾、完成了確定性搜尋,並將查詢結果嚴格鎖定在受測專案中。
今天,我們終於要邁出關鍵的一步:接上本機 Ollama,讓模型真正根據問題自主調用工具。
但在讓模型開口說話前,有一個軟體架構問題必須先解決:如果 search_code 回傳一種格式、find_importers 又回傳另一種格式,LLM 的 Prompt 就會充斥著滿滿的例外處理。
為了讓模型每次拿到工具回傳時都能穩定解析,我們強制讓所有查詢工具都輸出標準的 Evidence 結構:
{
"path": "src/rag_common.py",
"start_line": 1,
"end_line": 10,
"kind": "source",
"name": "src/rag_common.py",
"snippet": "from __future__ import annotations"
}
無論是文字搜尋、語法樹尋找定義,還是查找引用,輸出通通包含四個絕對要素:相對路徑、起訖行號、類型標記、以及真實程式碼片段。沒有模糊空間。
為了維持「唯讀安全」,我們在 app/ollama_runner.py 中建立死守的白名單分派器:
# app/ollama_runner.py (工具白名單核心)
TOOL_FUNCTIONS = {
"search_code": tools.search_code,
"find_symbol": tools.find_symbol,
"read_evidence": tools.read_evidence,
"find_importers": tools.find_importers,
"read_git_diff": tools.read_git_diff,
}
清單裡**徹底不存在 bash、write_file 或 run_command**。安全防護不是靠在 System Prompt 裡哀求模型「請你保持唯讀」,而是「你的世界裡根本沒有任何寫入工具」。
我們使用標準的 JSON Schema 向 Ollama 宣告工具規格,並處理 LLM 的 Tool Calling 迴圈:
# app/ollama_runner.py 核心邏輯
import json
import urllib.request
from typing import List, Dict, Any
OLLAMA_API_URL = "http://localhost:11434/api/chat"
# 宣告給 Ollama 的工具定義 (Schema)
TOOL_DEFINITIONS = [
{
"type": "function",
"function": {
"name": "find_importers",
"description": "找出專案中所有直接 import 指定模組的檔案與行號",
"parameters": {
"type": "object",
"properties": {
"module_name": {"type": "string", "description": "被引用的模組名稱,如 rag_common"}
},
"required": ["module_name"]
}
}
}
# 其餘工具依序宣告...
]
def run_agent_turn(prompt: str, model: str = "gemma2:9b") -> str:
messages = [{"role": "user", "content": prompt}]
# 1. 第一次向 Ollama 發送請求 (帶入工具白名單)
payload = {
"model": model,
"messages": messages,
"tools": TOOL_DEFINITIONS,
"stream": False
}
req = urllib.request.Request(
OLLAMA_API_URL,
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"}
)
with urllib.request.urlopen(req) as resp:
res = json.loads(resp.read().decode("utf-8"))
msg = res["message"]
# 2. 檢查模型是否選擇了工具呼叫
tool_calls = msg.get("tool_calls", [])
if not tool_calls:
return msg.get("content", "")
# 3. 嚴格比對白名單並執行
for call in tool_calls:
fn_name = call["function"]["name"]
fn_args = call["function"]["arguments"]
if fn_name not in TOOL_FUNCTIONS:
tool_output = {"error": f"Tool '{fn_name}' not allowed."}
else:
# 確定性分派
tool_output = TOOL_FUNCTIONS[fn_name](**fn_args)
# 4. 把工具真實輸出的 Evidence 放回上下文
messages.append(msg)
messages.append({
"role": "tool",
"content": json.dumps(tool_output)
})
# 5. 請模型整理最終回答
final_payload = {"model": model, "messages": messages, "stream": False}
final_req = urllib.request.Request(
OLLAMA_API_URL,
data=json.dumps(final_payload).encode("utf-8"),
headers={"Content-Type": "application/json"}
)
with urllib.request.urlopen(final_req) as resp:
final_res = json.loads(resp.read().decode("utf-8"))
return final_res["message"]["content"]
確認本機 Ollama 服務正常運作:
ollama list
確保使用的是支援 Tool Calling 的模型(例如 gemma2:9b 或同等規格):
NAME ID SIZE MODIFIED
gemma2:9b ff02c3a0a2e3 5.4 GB 2 days ago
對 mobileai-local-rag 下達自然語言提問:
uv run python -m app.ollama_runner "哪些檔案直接 import rag_common?"
經查閱專案索引,共有以下 4 個檔案直接 import 了 rag_common 模組:
1. src/build_index.py(第 4 行)
2. src/chat_reranker.py(第 6 行)
3. src/chat_reranker_guarded.py(第 6 行)
4. src/rag_chat.py(第 10 行)
這不是我們在程式碼裡寫死呼叫 find_importers,而是:
find_importers 工具。跑測試確認工具白名單全部註冊正確且無任何危險工具:
uv run pytest tests/unit/test_runner.py -q
5 passed in 0.04s
至此,我們成功完成了從「自然語言提問」到「本機確定性程式碼證據」的完整閉環!明天,我們將開始強化 AST 解析器,把 Python 複雜的 import ... as ... 別名以及符號定義一網打盡。