iT邦幫忙

2026 iThome 鐵人賽

DAY 17
0
AI Engineering

從 4,343 筆職缺到 AI Engineer:MLOps × GenAI Engineering 雙主軸實戰系列 第 17

Day 17:LLM 應用骨架 — 從 LINE Bot 到會自己決定的系統

  • 分享至 

  • xImage
  •  

2020 年我初次參加鐵人賽寫的是《用 LINE 串起多媒體系統》(GitHub),那 30 天在做的事,本質上是:接收使用者訊息 → 判斷意圖 → 呼叫對應的服務 → 組裝回應

六年後的今天,我們做的事情架構上驚人地相似,但有一件根本性的不同:

2020 年,「判斷意圖」是我用 if "天氣" in text 寫死的。
2026 年,這件事由模型決定,而且它還會自己決定要呼叫哪個 API、帶什麼參數。這一行差別,就是今天的主題。

今天要解決的問題

Day 16 的好提示變成能上線的服務,你會立刻撞到四個問題:

  1. 等待感:模型生成 500 字要 8 秒,使用者盯著空白畫面。
  2. 它不知道現在的事:使用者問「我的訂單到哪了」,模型只能瞎掰。
  3. 對話越來越長:第 20 輪時,每次請求都要送 8,000 token 的歷史。
  4. 它會壞:API 逾時、429 限流、回傳格式跑掉——而使用者只看到 500 錯誤。

📊 職缺訊號

「LLM 應用與系統整合」出現在 38.7% 的生成式 AI 職缺中,且是本期成長最多的任務訊號(+2.6%)。這反映了市場階段的轉變:從「做 demo」進入「接進既有系統」——而後者正是傳統後端工程師最大的優勢所在。


一、現象:六年來變的與沒變的

2020 LINE Bot 2026 LLM 應用
接收輸入 Webhook Webhook/HTTP/WebSocket
判斷意圖 if 規則或關鍵字比對 模型自己判斷(函式呼叫)
呼叫服務 寫死呼叫哪個 API 模型決定呼叫哪個、帶什麼參數
組裝回應 樣板字串 模型生成
回應時間 200 ms 2–10 秒(所以需要串流)
失敗模式 API 掛了 → 500 API 掛了、模型幻覺、參數帶錯、被使用者騙走
成本 幾乎為零 每次請求都在花錢

沒變的:系統整合的本質:認證、錯誤處理、重試、狀態管理、日誌。你在 2020 年學的那些,今天全部用得上。

變了的:控制流從「我寫死」變成「模型決定」。這帶來彈性,也帶來新的失敗模式:模型可能決定錯、參數帶錯、或被使用者的輸入誘導去做不該做的事(Day 25)。

二、原理:函式呼叫的迴圈,模型從不執行任何東西

這是最多人誤解的地方:

image

圖 17-1:函式呼叫的執行迴圈(示意時序)。關鍵是第 3 步與第 5 步之間——執行永遠由你的程式負責,這也是所有權限控制的著力點。

📌 這張圖最重要的一格是「權限檢查」

模型說要查訂單 A12345,不代表這個使用者有權看它。權限必須由你的程式在執行前檢查,絕不能依賴提示裡寫「只能查使用者自己的訂單」。Day 25 會看到,這正是「過度代理權」事故的根源。

三、動手:一個具備四項基本功的聊天後端

Lab:具函式呼叫能力的聊天後端架構

圖 17-2:聊天後端的架構(示意架構):串流輸出、函式呼叫迴圈、對話歷史管理,以及與企業內部 API 的對接。

3.1 串流:解決等待感

**串流輸出(Streaming / SSE)**降低首字延遲感(TTFT),並妥善處理中途斷線。

"""串流輸出:使用者在 TTFT 之後就開始看到字(Day 07 談過為什麼有效)。"""
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import json

app = FastAPI()

@app.post("/chat/stream")
async def chat_stream(req: ChatRequest):
    async def event_generator():
        try:
            stream = await client.chat.completions.create(
                model=MODEL, messages=build_messages(req), stream=True,
                max_tokens=800,          # 一定要設上限:省錢也防失控(Day 07)
            )
            async for chunk in stream:
                delta = chunk.choices[0].delta.content
                if delta:
                    yield f"data: {json.dumps({'delta': delta}, ensure_ascii=False)}\n\n"
            yield "data: [DONE]\n\n"
        except Exception as e:
            # 串流中途失敗也要讓前端知道,不要讓它一直轉圈
            yield f"data: {json.dumps({'error': str(e)})}\n\n"

    return StreamingResponse(event_generator(), media_type="text/event-stream")

3.2 函式呼叫:接上企業系統

工具描述需明確標註「何時使用」、回傳可讀的錯誤訊息供模型自我修正、限制工具數量(建議 10 個內)以避免 Token 浪費與選錯。

"""工具定義與執行迴圈。工具的描述品質,直接決定模型選得對不對。"""
TOOLS = [{
    "type": "function",
    "function": {
        "name": "lookup_order",
        # 描述要寫清楚「什麼時候該用」,而不只是「這是什麼」
        "description": "查詢單一訂單的出貨狀態與物流編號。"
                       "當使用者詢問訂單進度、何時送達、物流狀態時使用。",
        "parameters": {
            "type": "object",
            "properties": {
                "order_id": {"type": "string",
                             "description": "訂單編號,格式為 A 開頭加 5 碼數字"}
            },
            "required": ["order_id"],
        },
    },
}]

def lookup_order(order_id: str, *, user_id: str) -> dict:
    """實際執行——注意 user_id 是由應用層傳入,不是模型給的。"""
    order = db.get_order(order_id)
    if order is None:
        return {"error": "查無此訂單,請確認編號是否正確"}   # 可讀的錯誤讓模型能修正
    if order.user_id != user_id:                            # ← 權限檢查在這裡
        return {"error": "查無此訂單,請確認編號是否正確"}   # 不要洩漏「存在但無權」
    return {"status": order.status, "tracking": order.tracking_no}

async def run_with_tools(messages, user_id, max_rounds=5):
    """執行迴圈。max_rounds 是必要的護欄,防止無限來回(Day 22)。"""
    for _ in range(max_rounds):
        resp = await client.chat.completions.create(
            model=MODEL, messages=messages, tools=TOOLS)
        msg = resp.choices[0].message
        if not msg.tool_calls:
            return msg.content                    # 模型不需要工具了,回答完成
        messages.append(msg)
        for call in msg.tool_calls:
            args = json.loads(call.function.arguments)
            result = lookup_order(**args, user_id=user_id)    # 帶入真實身分
            messages.append({"role": "tool", "tool_call_id": call.id,
                             "content": json.dumps(result, ensure_ascii=False)})
    return "抱歉,這個問題我需要轉由專人協助。"      # 超過迭代上限的降級回應

三個生產級細節:工具描述寫「什麼時候該用」、錯誤訊息要可讀(讓模型能自我修正)、權限資訊由應用層注入而非模型提供

3.3 對話歷史:摘要記憶

採用小模型摘要舊對話,僅保留近期完整訊息,控制 Context Window 預算。

def build_messages(req, max_history_tokens=2000):
    """歷史超過預算時,把較早的對話摘要,保留近期完整訊息。"""
    history = load_history(req.session_id)
    if count_tokens(history) <= max_history_tokens:
        return [SYSTEM_PROMPT] + history + [{"role": "user", "content": req.text}]

    recent, older = history[-6:], history[:-6]
    summary = summarize(older)      # 用便宜的小模型做摘要即可
    return [SYSTEM_PROMPT,
            {"role": "system", "content": f"稍早對話摘要:{summary}"},
            *recent,
            {"role": "user", "content": req.text}]

3.4 錯誤處理:它一定會壞

僅針對可恢復錯誤(429、Timeout)實施指數退避重試,失敗時退回備援模型或友善提示,絕不外洩 Stack Trace。

from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type

@retry(stop=stop_after_attempt(3),
       wait=wait_exponential(multiplier=1, min=2, max=20),   # 指數退避
       retry=retry_if_exception_type((RateLimitError, APITimeoutError)))
async def call_llm(**kwargs):
    return await client.chat.completions.create(timeout=30, **kwargs)

注意只重試「可恢復」的錯誤:429 限流與逾時值得重試,400 參數錯誤重試一百次也不會成功。降級策略同樣重要:主模型失敗時退到備援模型、再失敗就回一句誠實的「暫時無法服務」——永遠不要讓使用者看到 stack trace

四、取捨:要不要用框架

選擇 適合 代價
原生 SDK(OpenAI/Anthropic) 應用邏輯明確、要完全掌控 自己處理重試、串流、工具迴圈(但也就這些)
LangChain/LlamaIndex 快速原型、需要大量現成整合 抽象層厚,除錯時要穿透多層;版本變動快
LangGraph 等狀態機框架 複雜的多步驟/多代理流程 學習曲線;簡單需求會過重

4.1 傳統後端工程師在這裡的優勢,比你想的大

如果您是從後端轉過來的,今天的內容應該讓你鬆一口氣——因為上面四項基本功,有三項半是本來就會的

  • 串流?曾做過 SSE 或 WebSocket。
  • 重試與退避?處理過第三方 API 不穩。
  • 狀態管理?曾做過 session 與快取。
  • 權限檢查?天天在做。

唯一真正新的東西,是「模型會自己決定要呼叫什麼」這件事帶來的不確定性。 而處理不確定性的方法,恰好也是後端的老本行:加上限、設逾時、做驗證、留降級路徑。

這也解釋了 Day 04 那張圖為什麼說前後端工程師轉生成式 AI 應用是「最短的路之一」——你缺的是 LLM 的行為知識,不是工程能力。反過來說,這也是為什麼「只會 prompt 不會工程」的人做不出能上線的系統。

4.2 一個容易忽略的成本陷阱:工具定義也要付錢

工具定義(TOOLS 那個 JSON)是每次請求都會送出的。如果你註冊了 20 個工具,每個描述 100 token,那就是 2,000 token 的固定支出——而且模型還要花力氣從 20 個裡面選一個,選錯的機率也上升

實務上的處理方式:

  • 工具數量控制在 10 個以內。 超過就考慮分成多個專門的代理,或用路由先決定領域再給對應工具。
  • 描述寫精準但不冗長。 「查詢訂單狀態」比「這個函式可以用來查詢使用者的訂單目前的狀態資訊」好——後者多花 token 又沒有更清楚。
  • 善用 prompt caching。 工具定義是穩定內容,放在前綴可以享折扣(Day 26)。

💡 我的建議:先不用框架

理由是:LLM 應用的核心迴圈(呼叫 → 檢查工具 → 執行 → 回填 → 再呼叫)大約就是上面那 20 行程式碼。自己寫一次,你會比較理解發生什麼事;用框架,會在出問題時不知道該從哪裡查起。

等到需要「複雜的狀態管理與流程分支」時再導入框架——那時已經知道框架在做什麼了。


今日小結

  • 六年前的 LINE Bot 與今天的 LLM 應用,系統整合的本質沒變(認證、重試、狀態、日誌),變的是控制流從「寫死」變成「模型決定」
  • 模型從不執行任何函式,它只輸出「請求」,執行永遠由你的程式負責——這是所有權限控制的著力點
  • 四項基本功:串流(解決等待感)、函式呼叫(接企業系統)、摘要記憶(控制歷史成本)、重試與降級(預設一定會壞)。
  • 工具設計三細節:描述寫「何時該用」、錯誤訊息要可讀、權限資訊由應用層注入
  • 只重試可恢復的錯誤;永遠不要讓使用者看到 stack trace
  • 先不用框架——核心迴圈只有 20 行,自己寫一次會比較知道在出事時知道從哪查。

限制與延伸思考

在將此架構推向更複雜的生產環境時,仍有三項潛在挑戰值得留意:首先,單純依賴小模型摘要歷史對話雖能節省 Token,但在高精度業務中容易遺失關鍵細節或產生二手幻覺,實務上常需搭配滑動視窗或快取檢索機制;其次,範例中的線性迴圈執行尚未支援多工具的平行呼叫(Parallel Function Calling)與依賴圖排程,可能限制複雜查詢的吞吐量;最後,連續觸發多個外部 API 與模型思考會產生延遲放大效應,對於高負載或耗時較長的任務,需進一步考慮引入非同步任務隊列與即時狀態回饋機制以維持良好體驗。

明天預告

明天進入生成式 AI 職缺中出現頻率最高的主題(48.1%):RAG。我會用三天完整處理它,明天先講索引端——那個「八成的 RAG 失敗,在使用者提問之前就已經決定了」的階段。

延伸閱讀


上一篇
Day 16:從 Prompt 到 Context Engineering — 輸入視窗是一份預算
下一篇
Day 18:RAG(一)索引端 — 八成的失敗在提問之前就決定了
系列文
從 4,343 筆職缺到 AI Engineer:MLOps × GenAI Engineering 雙主軸實戰18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言