用狀態機把 13GB 小模型變成工程實習生

用狀態機把 13GB 小模型變成工程實習生

📌 本文重點

  • 小模型別當全能 Agent,要當被流程管控的小工
  • 用顯式狀態機拆任務,大幅提升穩定性與可回滾性
  • 每步輸出 JSON + schema 驗證,讓小模型也能穩定改碼

只靠 prompt 堆疊,13GB 本地模型在中大型改碼任務幾乎必翻車:上下文飄掉、一次回錯一堆檔、改到一半忘記需求。把模型包進顯式狀態機,把「一次大任務」拆成可恢復的子任務,可以在不改模型的前提下,大幅提升穩定性、可觀測性與可測試性——正是那篇 13.8GB 模型從 2/10 變成 10/10 的核心做法。

💡 關鍵: 只改調用方式與流程設計,就能把同一顆 13.8GB 小模型的表現從 2/10 拉到 10/10。


重點說明

1. 小模型為什麼在長對話裡特別容易翻車?

從工程視角,有三個根本原因:

  1. token 預算太小 + 資訊密度太高
    13GB 級(多是 7B〜13B 參數)在 4k–16k context 內要同時塞:需求、專案結構、幾個檔案內容、測試結果、對話歷史,關鍵訊息會被截斷或壓縮到模型抓不到

  2. 上下文漂移(context drift)
    多輪長對話時,你不可能每次都重貼完整需求與檔案。模型只能靠「語意回憶」之前說過什麼,多輪後任務邊界就開始模糊:忘記原本的 constraint、改到不該動的檔案、把舊 bug 當新需求。

  3. 一次性決策成本過高
    傳統「一條大 prompt + chain-of-thought」會在單輪裡要求:理解需求 → 找檔 → 設計改動 → 寫碼 → 自我檢查。這在 token 限制與小模型推理能力下,極易在中間任一步 hallucinate,之後又沒有明確的 rollback 機制。

關鍵結論: 小模型不適合當「一次性全能 Agent」,更適合當「被嚴格流程控制的小工」,讓狀態機負責 long-term 記憶與決策邊界。

💡 關鍵: 把 long-term 記憶與流程決策交給狀態機,小模型只做局部推理,能顯著降低翻車率。


2. 用顯式狀態機拆解大任務:核心設計

把「改造一個中小型專案」拆成明確的 State + Transition

常見狀態設計可以是:

  1. DISCOVER_PROJECT:掃描 repo、建立檔案索引
  2. PLAN_CHANGE:根據需求與索引產生修改計畫(檔案清單、步驟)
  3. EDIT_FILE:逐檔案修改(step-by-step)
  4. RUN_TESTS:執行測試、收集結果
  5. ROLLBACK_OR_FIX:測試失敗→嘗試修復或回滾
  6. DONE / FAILED:終止狀態

每個狀態都只給模型 極簡上下文 + 明確輸入/輸出 schema,例如在 EDIT_FILE

  • 輸入:
  • 需求摘要(短)
  • 該檔案目前內容(或片段)
  • 計畫中對此檔案的變更描述
  • 輸出:
  • 結構化 JSON:{"status": "ok|skip|abort", "patch": "...diff..."}

轉移條件示例:

  • DISCOVER_PROJECTPLAN_CHANGE:索引成功建立
  • PLAN_CHANGEEDIT_FILE:生成的計畫通過 schema 檢查
  • EDIT_FILERUN_TESTS:所有目標檔案處理完
  • RUN_TESTS
  • 全綠 → DONE
  • 有失敗 + 可定位 → EDIT_FILE (targeted fix)
  • 多次失敗 → ROLLBACK_OR_FIX

失敗重試策略與超時機制

  • 每個狀態設定 max_retries,例如 2–3 次,超過則標記為 FAILED 或轉 ROLLBACK_OR_FIX
  • 每次 LLM 回應必經:
  • JSON schema 驗證
  • domain guard(例如禁止刪除大量無關 code)
  • 超時機制
  • 單次呼叫 timeout(例如 60s),保障工作流不被卡死
  • 整個工作流 wall-clock timeout(例如 30 分鐘),方便在 CI 或自動化工具中運行

💡 關鍵: 把重試、超時、回滾寫死在狀態機邏輯裡,比指望 prompt 提醒模型「要小心」可靠太多。


3. 實作範例:13GB 本地模型改造專案(Python)

以下是精簡版 pseudo-code,示範如何把本地模型包在狀態機裡,跑 step-by-step 編碼、測試與回滾。假設:

  • 使用 vLLM / llama.cpp server 暴露出 OpenAI-compatible API
  • GPU:3060 12GB,模型用 Q4 / Q5 量化
import enum
import json
import subprocess
from dataclasses import dataclass
from typing import Dict, Any, List
import requests

OPENAI_BASE = "http://localhost:8000/v1"
MODEL_NAME = "local-13b-q4"

class State(enum.Enum):
    DISCOVER_PROJECT = "DISCOVER_PROJECT"
    PLAN_CHANGE = "PLAN_CHANGE"
    EDIT_FILE = "EDIT_FILE"
    RUN_TESTS = "RUN_TESTS"
    ROLLBACK_OR_FIX = "ROLLBACK_OR_FIX"
    DONE = "DONE"
    FAILED = "FAILED"

@dataclass
class Context:
    repo_path: str
    requirement: str
    file_index: Dict[str, Any] = None
    plan: List[Dict[str, Any]] = None
    current_file_idx: int = 0
    test_result: str = ""


def call_llm(system_prompt: str, user_prompt: str, max_tokens: int = 1024) -> str:
    resp = requests.post(
        f"{OPENAI_BASE}/chat/completions",
        json={
            "model": MODEL_NAME,
            "messages": [
                {"role": "system", "content": system_prompt},
                {"role": "user", "content": user_prompt},
            ],
            "temperature": 0.2,
            "max_tokens": max_tokens,
        },
        timeout=60,
    )
    resp.raise_for_status()
    return resp.json()["choices"][0]["message"]["content"]


def discover_project(ctx: Context) -> Context:
    # 這裡可以用 ripgrep / fd 產生檔案清單,略
    ctx.file_index = {"files": ["src/a.py", "src/b.py"], "tests": ["tests/test_a.py"]}
    return ctx


def plan_change(ctx: Context) -> Context:
    system = """你是資深工程師,輸出 JSON,字段: steps: [{file, description}]。"""
    user = f"需求: {ctx.requirement}\n可修改檔案: {ctx.file_index['files']}\n請產生最多 10 個步驟。"
    raw = call_llm(system, user)
    try:
        plan = json.loads(raw)
    except Exception:
        raise ValueError("PLAN_CHANGE: model output not JSON")
    ctx.plan = plan["steps"]
    ctx.current_file_idx = 0
    return ctx


def apply_patch(repo_path: str, file: str, patch: str):
    # 建議用 unified diff + `patch` 指令,這裡簡化處理
    with open(f"{repo_path}/{file}", "w", encoding="utf-8") as f:
        f.write(patch)


def edit_file(ctx: Context) -> Context:
    step = ctx.plan[ctx.current_file_idx]
    file_path = step["file"]
    with open(f"{ctx.repo_path}/{file_path}", encoding="utf-8") as f:
        content = f.read()

    system = """你只負責修改單一檔案。輸出 JSON: {status, patch}。
    - status: ok | skip | abort
    - patch: 完整檔案內容,不要解釋文字。"""

    user = f"需求: {ctx.requirement}\n此步驟: {step['description']}\n原始內容:\n{content[:4000]}"
    raw = call_llm(system, user, max_tokens=2048)
    try:
        out = json.loads(raw)
    except Exception:
        raise ValueError("EDIT_FILE: invalid JSON")

    if out["status"] == "ok":
        apply_patch(ctx.repo_path, file_path, out["patch"])
    elif out["status"] == "abort":
        raise RuntimeError("Model aborted edit")

    ctx.current_file_idx += 1
    return ctx


def run_tests(ctx: Context) -> Context:
    proc = subprocess.run(["pytest"], cwd=ctx.repo_path, capture_output=True, text=True)
    ctx.test_result = proc.stdout + "\n" + proc.stderr
    return ctx


def rollback_or_fix(ctx: Context) -> Context:
    # 真實情況應該搭配 git: reset --hard HEAD~1 或建立 branch
    # 這裡示意:交給模型看測試輸出,決定要修哪個檔案
    system = "請從測試輸出中找出最可能需要修改的單一檔案,輸出 JSON: {file, reason}"
    user = ctx.test_result[:4000]
    raw = call_llm(system, user)
    try:
        out = json.loads(raw)
    except Exception:
        raise ValueError("ROLLBACK_OR_FIX: invalid JSON")

    # 根據 out['file'] 重新插入 plan
    ctx.plan.insert(ctx.current_file_idx, {"file": out["file"], "description": out["reason"]})
    return ctx


def run_state_machine(ctx: Context):
    state = State.DISCOVER_PROJECT
    retries: Dict[State, int] = {s: 0 for s in State}
    MAX_RETRIES = 2

    while True:
        try:
            if state == State.DISCOVER_PROJECT:
                ctx = discover_project(ctx)
                state = State.PLAN_CHANGE

            elif state == State.PLAN_CHANGE:
                ctx = plan_change(ctx)
                state = State.EDIT_FILE

            elif state == State.EDIT_FILE:
                if ctx.current_file_idx >= len(ctx.plan):
                    state = State.RUN_TESTS
                else:
                    ctx = edit_file(ctx)

            elif state == State.RUN_TESTS:
                ctx = run_tests(ctx)
                if "failed" in ctx.test_result:
                    state = State.ROLLBACK_OR_FIX
                else:
                    state = State.DONE

            elif state == State.ROLLBACK_OR_FIX:
                ctx = rollback_or_fix(ctx)
                state = State.EDIT_FILE

            elif state in (State.DONE, State.FAILED):
                return state, ctx

        except Exception as e:
            print(f"State {state} error: {e}")
            retries[state] += 1
            if retries[state] > MAX_RETRIES:
                return State.FAILED, ctx


if __name__ == "__main__":
    ctx = Context(repo_path="/path/to/repo", requirement="把 API v1 換成 v2 並修正測試")
    final_state, final_ctx = run_state_machine(ctx)
    print("Final state:", final_state)

重點:

  • 模型只做 局部、可回滾的決策(例如一次只改一檔)。
  • 工作流邏輯(狀態、重試、回滾)都在 可測試的 Python 函式 中,而不是藏在 prompt 裡。

若用 TypeScript + LangGraph / 自行寫狀態機,模式相同:每個 Node 是一個狀態,Edge 由測試結果與 JSON 輸出決定。


4. 與「prompt + chain-of-thought」相比的實際好處

  1. 穩定性
  2. CoT 依賴模型「自己監督自己」,小模型的推理錯誤會被往後 propagate,沒有硬性 checkpoint。
  3. 狀態機把流程切成多個 可檢查的邏輯節點,每步都能強制過 schema、判斷失敗與回滾。

  4. 成本與資源

  5. 單輪 prompt 巨大 → token 費用高,且在本地 GPU 上速度慢。
  6. 狀態機讓每輪上下文更短、更聚焦,在 3060 12GB + Q4 模型上可以穩定跑 多輪短對話,總延遲往往比一輪巨 prompt 更好控制。

  7. 觀測性(logging / trace)

  8. 把每個狀態轉移、LLM input/output、git diff 全記錄(例如存到 SQLite / OpenTelemetry trace),可以:

    • 後覽失敗案例
    • 做離線分析:哪個狀態最常出錯?哪種需求最難?
  9. 可測試性

  10. 傳統做法難以單元測試 Agent:prompt 無法 deterministic。
  11. 狀態機可以用 fake LLM 或 replay 真實輸出,對每個 state handler 寫 unit test,例如:當測試結果是某種錯誤訊息時,ROLLBACK_OR_FIX 應插入哪個 plan。

建議與注意事項

1. 避免狀態爆炸

  • 限制狀態數量在 5–10 個,複雜度放在狀態內部的子函式,而不是新增一堆細碎狀態。
  • 優先建立 通用狀態模板PLAN / EXECUTE / VERIFY / RECOVER 四類,大部分工程任務都能套這個骨架。

2. 處理 hallucination 與非法狀態

  • 所有 LLM 輸出一律要求 JSON + schema 驗證,非法就走重試邏輯。
  • EDIT_FILE 等關鍵步驟設計 domain guard
  • 檢查 patch 是否刪除超過 X% 行數;
  • 檢查是否涉及黑名單檔案(例如 config、CI YAML)。

3. 設計「保守模式」避免改壞檔案

建議預設開啟:

  1. 所有改動先走分支 / 工作目錄拷貝
  2. 狀態機只在 temp branch/dir 上動手,最後才由人類 review + merge。

  3. 只允許白名單檔案被修改

  4. PLAN_CHANGE 事先產出可修改清單,EDIT_FILE 收到不在清單內的檔案時直接拒絕。

  5. 必備 diff 檢查

  6. 每次改檔後,log 一份 git diff。
  7. 可以加一個 HUMAN_APPROVAL 狀態,在 CI 或 IDE 裡讓人按「Approve」才繼續。

4. 3060 12GB 本地 GPU 的實務建議

  • 模型:選 Q4_K_M / Q5 量化的 7B–13B 開源模型(如 Llama 系家族、Qwen 等),在 Agentic coding 任務上實測延遲可接受。
  • 推理引擎:
  • llama.cpp / ollama:部署簡單,適合單機開發。
  • vLLM:若你需要高併發與更細緻的 batching,可考慮,但對記憶體稍敏感。
  • 參數建議:
  • max_tokens 控制在 512–2048,依狀態不同調整。
  • temperature 低於 0.3,減少 hallucination。
  • 避免在單輪塞完整檔案,改成 片段 + 明確上下文(例如「你只看這個 function」)。

5. 映射到現有 Agent framework 的模式

這套思路可以直接映射到:

  • LangChain / LangGraph
  • 每個狀態 = 一個 Node(通常是 Tool + LLM)。
  • 轉移條件透過 conditional edges 判斷 JSON output 中的 status / next_state
  • 用 LangGraph 的 checkpointingContext 存到外部 store,可做恢復與可視化。

  • LangMCP(可檢視狀態的 Agent framework)

  • Context 中的 file_index / plan / test_result 全部納入 inspectable state
  • 除錯時可以直接在 UI 裡看到「Agent 在哪一步做錯決策」,而不是只看 tokens trace。

  • Claude Code / goal-workflow 類工具

  • 把這裡的狀態機當作後端 orchestrator,前端 IDE 只負責:設定 goal → 顯示 plan → 顯示每步 diff / 測試 → 提供人類 approve。

總結工程模式:

LLM 做局部推理 + 生成,狀態機做長期決策 + 記憶 + 恢復。
把「智慧」從模型本體,搬到你可控、可測、可觀測的工作流程式碼中,13GB 小模型也能在工程任務裡穩定交付 10/10 的結果。


🚀 你現在可以做的事

  • 在本地架一個 llama.cppvLLM 的 OpenAI-compatible 服務,載入一顆 7B–13B Q4/Q5 模型試跑上文的狀態機範例
  • 把你現有的「一條大 prompt 改碼流程」改寫成 5–10 個明確狀態,並為每步定義 JSON schema 與 max_retries
  • 在 CI 或開發機中為這套狀態機加上 logging / trace(例如 SQLite 或 OpenTelemetry),實際分析哪個 state 最常出錯

留言

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *