Day 17:LLM 應用骨架 — 從 LINE Bot 到會自己決定的系統
2020 年我初次參加鐵人賽寫的是《用 LINE 串起多媒體系統》(GitHub),那 30 天在做的事,本質上是:接收使用者訊息 → 判斷意圖 → 呼叫對應的服務 → 組裝回應。
六年後的今天,我們做的事情架構上驚人地相似,但有一件根本性的不同:
2020 年,「判斷意圖」是我用
if "天氣" in text寫死的。 2026 年,這件事由模型決定,而且它還會自己決定要呼叫哪個 API、帶什麼參數。這一行差別,就是今天的主題。
今天要解決的問題
把 Day 16 的好提示變成能上線的服務,你會立刻撞到四個問題:
- 等待感:模型生成 500 字要 8 秒,使用者盯著空白畫面。
- 它不知道現在的事:使用者問「我的訂單到哪了」,模型只能瞎掰。
- 對話越來越長:第 20 輪時,每次請求都要送 8,000 token 的歷史。
- 它會壞: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)。
二、原理:函式呼叫的迴圈,模型從不執行任何東西
這是最多人誤解的地方:

圖 17-1:函式呼叫的執行迴圈(示意時序)。關鍵是第 3 步與第 5 步之間——執行永遠由你的程式負責,這也是所有權限控制的著力點。
📌 這張圖最重要的一格是「權限檢查」
模型說要查 訂單 A12345,不代表這個使用者有權看它。權限必須由你的程式在執行前檢查,絕不能依賴提示裡寫「只能查使用者自己的訂單」。Day 25 會看到,這正是「過度代理權」事故的根源。
三、動手:一個具備四項基本功的聊天後端

圖 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 "抱歉,這個問題我需要轉由專人協助。" # 超過迭代上限的降級回應
三個生產級細節:工具描述寫「什麼時候該用」、錯誤訊息要可讀(讓模型能自我修正)、權限資訊由應用層注入而非模型提供。
