AG Day 3 對話 API 深入:messages、roles 與 system prompt
執行需求:CPU+API key。在上一篇 AG Day 2(原文連結)中,我們使用 uv 建立了現代化的專案骨架、規範了以環境變數為核心的金鑰安全體系,並成功初始化了 SQLite 知識庫儲存層。今天,我們要正式進入大型語言模型(LLM)運作的核心地帶——對話 API。對於普通的問答機器人,開發者往往只需將使用者的單次輸入包裝成 HTTP 請求即可;但對於 AI Agent(人工智慧代理)系統而言,模型所接收的每一個訊息(Message)串列,都是代理理解當前環境、回顧歷史決策與規劃下一步行動的唯一依據。本篇文章將深入剖析 system、user、assistant 與 tool 四大角色的運作本質,實作支援 --dry-run 離線模擬的客戶端封裝,並打造保證工具呼叫原子性的智慧滑動視窗修剪器,徹底解決長多輪對話中的上下文溢位難題。
引言
當我們探討 AI Agent 的推理機制時,許多人容易將注意力集中在高層次的提示詞技巧或複雜的框架圖譜上,卻忽略了最底層的通訊協定細節。大型語言模型本質上是一個預測下一個 Token 的自迴歸機率分佈器。現代 Chat Completion API 之所以能夠呈現出多角色對話的型態,是因為在底層將帶有角色標籤的結構化訊息串列,透過特殊的分隔標記(Special Tokens,例如 <|im_start|> 與 <|im_end|>)拼接成單一的連續文本流。
在長任務代理的運作過程中,隨著資料檢索、工具呼叫與段落撰寫的反覆進行,對話歷史長度會迅速膨脹。如果任由訊息無限制累積,系統將面臨兩大致命打擊:首先,上下文長度會突破模型硬體或架構設定的最大上限(Context Window Limit),導致 API 拒絕服務並拋出錯誤;其次,即便未達上限,每一次模型呼叫都必須將龐大的歷史文本重新傳輸與運算,導致延遲劇烈攀升與高額的費用消耗(模型呼叫費用一律以官方文件為準)。因此,身為 Agent 工程師,我們必須具備精確控制對話狀態、嚴格界定角色職責,以及在不破壞工具呼叫完整性的前提下安全修剪歷史訊息的能力。
原理/觀念
拆解四大對話角色的職責與權限層級
在現代主流的對話通訊規範中,訊息陣列主要由以下四種角色(Roles)構成,各自承載著截然不同的系統權責:
- system(系統角色):位於訊息陣列的最前緣,用於定義代理的核心人格、專業領域、絕對禁止的危險行為、工具呼叫偏好以及輸出規格。在模型的權重設計與對齊訓練中,
system角色通常具有最高的約束力與指令優先級。 - user(使用者角色):代表發起任務的終端操作者,或是排程系統派發的具體目標指令。在代理的自主迴圈中,最初的研究題目通常以此角色傳入。
- assistant(助理角色):代表大型語言模型自身的輸出。它不僅包含模型給出的文字推理(Thought)或最終回覆,在工具呼叫情境下,更包含模型主動要求的
tool_calls結構(包含要執行的工具名稱與結構化引數)。 - tool(工具角色):代表本機系統執行完外部程式碼(如搜尋引擎、資料庫查詢、計算工具)後的真實回傳結果。每個
tool訊息必須攜帶與模型要求完全相符的tool_call_id,形成閉環。
上下文視窗管理與滑動修剪的原子性挑戰
管理 Agent 的對話歷史遠比管理普通對話困難。最常見的初學者錯誤是採用簡單的「保留最新 N 筆訊息」邏輯。然而,這種粗暴的修剪方式在 Agent 系統中會引發兩大災難:
- 系統提示詞丟失:如果不小心把位於
messages[0]的system提示詞截斷,代理會瞬間喪失角色約束與工具指令,行為變得飄忽不定。 - 工具呼叫對(Tool Call Pair)被撕裂:如果一筆包含
tool_calls的assistant訊息被修剪掉了,但後續的tool回傳訊息依然留在歷史中,或者反過來保留了tool_calls卻丟失了tool回覆,API 伺服器會立即回傳HTTP 400 Invalid Request,因為協定嚴格要求每一個工具呼叫必須緊跟對應的工具結果。
因此,我們必須實作「原子性滑動視窗(Atomic Sliding Window)」機制:永遠固化 system 訊息,並在修剪舊訊息時,以「使用者輪次」或「完整的工具呼叫對」為最小不可分割單位進行批次淘汰。
完整實作
今天我們將在 src/research_agent/llm.py 中實作一個標準化、高強度的對話 API 封裝模組。本實作不僅支援真實的 API 呼叫,更完整內建離線模擬模式(--dry-run),即使沒有配置 API 金鑰也能完整執行並產生符合規格的示範輸出。
第一步:建立嚴謹的訊息資料模型與角色列舉。我們使用 Python 3.13 的 typing 與 dataclasses:
# research-agent/src/research_agent/llm_types.py
from dataclasses import dataclass
from typing import Literal, List, Dict, Any
RoleType = Literal["system", "user", "assistant", "tool"]
@dataclass
class ChatMessage:
"""標準化對話訊息資料結構"""
role: RoleType
content: str
tool_call_id: str | None = None
tool_calls: List[Dict[str, Any]] | None = None
def to_dict(self) -> Dict[str, Any]:
"""將訊息物件序列化為 API 相容的字典格式"""
payload: Dict[str, Any] = {
"role": self.role,
"content": self.content
}
if self.tool_call_id:
payload["tool_call_id"] = self.tool_call_id
if self.tool_calls:
payload["tool_calls"] = self.tool_calls
return payload
在將訊息陣列送入修剪器之前,我們需要一個能夠快速估算 Token 消耗量的輕量評估工具,作為在程式碼中決定是否觸發滑動視窗修剪的量化指標:
# research-agent/src/research_agent/token_counter.py
from typing import List
from research_agent.llm_types import ChatMessage
def estimate_tokens_for_messages(messages: List[ChatMessage]) -> int:
"""
輕量級 Token 消耗量估算器。
在不依賴額外大型二進位套件的離線環境下,採用字元與單詞混合比例推估。
英文約 4 字元 1 Token,中文字元約 1 到 2 Tokens,每條訊息外加 4 Tokens 角色標記開銷。
"""
total_tokens = 0
for msg in messages:
total_tokens += 4 # 每則訊息基礎結構標記開銷
content = msg.content
cjk_count = sum(1 for ch in content if '\u4e00' <= ch <= '\u9fff')
other_count = len(content) - cjk_count
# 粗估:中文字約 1.5 tokens,英數符號約 0.35 tokens
total_tokens += int(cjk_count * 1.5 + other_count * 0.35)
if msg.tool_calls:
total_tokens += 20 # 工具呼叫元資料開銷估算
total_tokens += 2 # 結尾助理回覆啟動標記
return total_tokens
第二步:實作智慧型滑動視窗修剪器 prune_chat_history()。此函式確保無論對話如何膨脹,system 提示詞永遠保留,且工具呼叫與工具回傳訊息永遠成對存在:
# research-agent/src/research_agent/pruner.py
from typing import List
from research_agent.llm_types import ChatMessage
def prune_chat_history(
messages: List[ChatMessage],
max_turns: int = 4
) -> List[ChatMessage]:
"""
依據最大對話輪次修剪歷史紀錄。
保留原則:
1. 第一條 system 提示詞永遠保留。
2. 僅保留最近 max_turns 組有效的問答/工具互動。
3. 絕不切斷 assistant(tool_calls) 與 tool 回應之間的關聯。
"""
if len(messages) <= 2:
return list(messages)
system_message = messages[0] if messages[0].role == "system" else None
remaining = messages[1:] if system_message else messages[:]
# 從最新訊息往前掃描,確保工具呼叫對的完整性
pruned: List[ChatMessage] = []
i = len(remaining) - 1
retained_turns = 0
while i >= 0 and retained_turns < max_turns:
msg = remaining[i]
# 若是工具訊息,必須連同其前面的 assistant(tool_calls) 一併保留
if msg.role == "tool":
tool_group: List[ChatMessage] = [msg]
prev_idx = i - 1
while prev_idx >= 0 and remaining[prev_idx].role == "tool":
tool_group.insert(0, remaining[prev_idx])
prev_idx -= 1
if prev_idx >= 0 and remaining[prev_idx].role == "assistant" and remaining[prev_idx].tool_calls:
tool_group.insert(0, remaining[prev_idx])
i = prev_idx
pruned = tool_group + pruned
retained_turns += 1
elif msg.role in ("user", "assistant"):
pruned.insert(0, msg)
if msg.role == "user":
retained_turns += 1
i -= 1
final_result: List[ChatMessage] = []
if system_message:
final_result.append(system_message)
final_result.extend(pruned)
return final_result
第三步:實作 System Prompt 構建器。我們將研究助理的核心目標、邊界約束與輸出規格模組化:
# research-agent/src/research_agent/prompts.py
RESEARCH_SYSTEM_PROMPT_TEMPLATE = """你是由研究團隊構建的高階自主研究助理(Research Agent)。
你的決策模型變數為:{model_name}。
工作準則:
1. 嚴格客觀:依據可靠外部來源與事實進行分析,禁止無中生有與虛構數據。
2. 步驟自律:在執行複雜任務前,先思考所需步驟;若需外部資訊,主動呼叫相關檢索工具。
3. 邊界防護:嚴禁執行未獲授權的危險操作;對於不確定的結論,必須明確標注出處或註明以官方文件為準。
4. 結構化回覆:在完成所有工具查核後,提供條理分明的研究摘要與引用出處。
"""
def build_system_prompt(model_name: str) -> ChatMessage:
"""生成經過參數化的標準系統提示詞"""
content = RESEARCH_SYSTEM_PROMPT_TEMPLATE.format(model_name=model_name)
return ChatMessage(role="system", content=content)
第四步:實作核心 LLM 客戶端封裝 src/research_agent/llm.py,支援以環境變數動態挑選模型,並提供流暢的離線模擬運作模式:
# research-agent/src/research_agent/llm.py
import os
import json
from typing import List, Dict, Any
from research_agent.llm_types import ChatMessage
from research_agent.pruner import prune_chat_history
class LLMClient:
"""對話模型統一調度客戶端"""
def __init__(self, dry_run: bool | None = None):
self.model_name = os.getenv("RESEARCH_AGENT_MODEL", "mock-research-model")
# 若未明示指定 dry_run,則檢查是否缺乏常用 API 金鑰
has_key = bool(os.getenv("OPENAI_API_KEY") or os.getenv("ANTHROPIC_API_KEY"))
self.dry_run = dry_run if dry_run is not None else (not has_key)
def chat_completion(
self,
messages: List[ChatMessage],
temperature: float = 0.2
) -> ChatMessage:
"""
發送對話請求並取得模型回應。
若處於 dry_run 模式,則自動回傳具備代表性的離線模擬結果。
"""
if self.dry_run:
return self._mock_completion(messages)
# 真實通訊邏輯(示範呼叫標準相容端點)
try:
import httpx
api_key = os.getenv("OPENAI_API_KEY", "")
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
payload = {
"model": self.model_name,
"messages": [m.to_dict() for m in messages],
"temperature": temperature
}
with httpx.Client(timeout=30.0) as client:
resp = client.post("https://api.openai.com/v1/chat/completions", headers=headers, json=payload)
resp.raise_for_status()
data = resp.json()
choice = data["choices"][0]["message"]
return ChatMessage(
role="assistant",
content=choice.get("content") or "",
tool_calls=choice.get("tool_calls")
)
except Exception as exc:
# 當真實通訊失敗時,印出告警並優雅回退
return ChatMessage(
role="assistant",
content=f"真實 API 連線出現異常({exc}),建議檢查網路設定或啟用離線模擬模式。"
)
def _mock_completion(self, messages: List[ChatMessage]) -> ChatMessage:
"""離線模擬推理引擎,依據最後一條訊息給予合理的回覆"""
last_msg = messages[-1]
if last_msg.role == "user":
return ChatMessage(
role="assistant",
content="我已理解您的研究需求。為了取得具權威性的數據,我將先查詢相關技術規格庫。",
tool_calls=[{
"id": "call_mock_991",
"type": "function",
"function": {
"name": "lookup_specs",
"arguments": json.dumps({"target": "邊緣 AI 運算單元"})
}
}]
)
elif last_msg.role == "tool":
return ChatMessage(
role="assistant",
content=(
f"依據 [{self.model_name}] 讀取的工具資料,邊緣運算單元在 2026 年已全面支援低位元量化。"
"綜合分析顯示其效能達到前代標準的 2.5 倍,符合預期指標。"
)
)
else:
return ChatMessage(
role="assistant",
content=f"[{self.model_name}] 收到對話,請提供進一步指令。"
)
第五步:撰寫測試腳本 demo_dialogue.py,驗證多輪對話訊息累積、工具呼叫角色閉環,以及歷史修剪器的實際防護成效:
# research-agent/demo_dialogue.py
from research_agent.llm import LLMClient
from research_agent.prompts import build_system_prompt
from research_agent.llm_types import ChatMessage
from research_agent.pruner import prune_chat_history
def main():
print("=== 啟動對話 API 與角色狀態閉環驗證 ===\n")
client = LLMClient(dry_run=True)
# 1. 建立固化的 System Prompt
system_msg = build_system_prompt(client.model_name)
conversation: list[ChatMessage] = [system_msg]
# 2. 第一輪:使用者提問
user_task = "請評估 2026 年邊緣運算單元的效能增益與架構變化。"
print(f"[User]: {user_task}")
conversation.append(ChatMessage(role="user", content=user_task))
# 模型決策第一步
reply_1 = client.chat_completion(conversation)
conversation.append(reply_1)
print(f"[Assistant]: {reply_1.content}")
if reply_1.tool_calls:
call = reply_1.tool_calls[0]
print(f" -> 觸發工具呼叫:{call['function']['name']},參數:{call['function']['arguments']}")
# 3. 模擬系統執行工具並回傳 tool 角色訊息
mock_tool_output = "【硬體規格庫】:最新架構支援 INT4/FP8 混合精度,運算密度提升 150%。"
tool_msg = ChatMessage(
role="tool",
content=mock_tool_output,
tool_call_id=call["id"]
)
conversation.append(tool_msg)
print(f"[Tool Response ({call['id']})]: {mock_tool_output}")
# 模型決策第二步:彙整工具資訊
reply_2 = client.chat_completion(conversation)
conversation.append(reply_2)
print(f"[Assistant]: {reply_2.content}\n")
# 4. 驗證滑動視窗修剪
print(f"修剪前總訊息數:{len(conversation)}")
pruned_msgs = prune_chat_history(conversation, max_turns=1)
print(f"修剪後總訊息數(保留 1 輪):{len(pruned_msgs)}")
print("修剪後訊息角色序列:", [m.role for m in pruned_msgs])
assert pruned_msgs[0].role == "system", "錯誤:系統提示詞遭到意外修剪!"
print("驗證通過:系統提示詞完好如初,工具對保持原子完整!")
if __name__ == "__main__":
main()
第六步:執行驗證腳本,觀察多輪對話的角色轉換過程:
python demo_dialogue.py
終端機將會輸出如下標準結構化追蹤紀錄(示範輸出):
=== 啟動對話 API 與角色狀態閉環驗證 ===
[User]: 請評估 2026 年邊緣運算單元的效能增益與架構變化。
[Assistant]: 我已理解您的研究需求。為了取得具權威性的數據,我將先查詢相關技術規格庫。
-> 觸發工具呼叫:lookup_specs,參數:{"target": "邊緣 AI 運算單元"}
[Tool Response (call_mock_991)]: 【硬體規格庫】:最新架構支援 INT4/FP8 混合精度,運算密度提升 150%。
[Assistant]: 依據 [mock-research-model] 讀取的工具資料,邊緣運算單元在 2026 年已全面支援低位元量化。綜合分析顯示其效能達到前代標準的 2.5 倍,符合預期指標。
修剪前總訊息數:5
修剪後總訊息數(保留 1 輪):4
修剪後訊息角色序列: ['system', 'assistant', 'tool', 'assistant']
驗證通過:系統提示詞完好如初,工具對保持原子完整!
常見錯誤與踩雷
在實際整合底層對話 API 時,以下是生產環境中最常引發系統崩潰的雷區:
- 工具呼叫對的撕裂(Broken Tool Call Pair):如果開發者只按長度截斷歷史,導致訊息佇列中存在帶有
tool_calls的assistant訊息,其後方卻缺少對應的tool訊息;或者只保留了tool訊息卻丟棄了前面的assistant訊息。大部分主流供應商的 API 伺服器會直接拋出400 Invalid parameter: messages錯誤並中斷執行。 - 直接字串拼接導致提示詞注入(Prompt Injection):有些開發者將使用者輸入的字串直接與
system prompt進行f-string拼接(例如f"你是研究員,使用者的任務是:{user_input}")。這會讓使用者輸入輕易覆蓋系統的核心安全約束。正確的做法是將使用者輸入嚴格放置在user角色訊息中,利用底層的分隔標記與系統指令做出明確隔離。 - 未記錄 Assistant 回覆導致模型失憶:在多步驟 Agent 迴圈中,有些工程師在呼叫工具後,忘記把模型上一輪生成的
assistant訊息推進歷史陣列中,而只推入了tool結果。這會破壞對話狀態的連貫性,使模型無法理解這筆工具結果到底回應的是自己的哪一項要求。 - 迷信 System Prompt 的絕對安全性:儘管
system訊息權限最高,但它並不是絕對無法突破的數位沙盒。如果模型受到特定對抗性攻擊(Jailbreak),仍可能發生行為偏移。因此在生產架構中,涉及關鍵資源的工具端必須同時配置後端驗證與權限控制(Defense in Depth)。
效能與實務提醒
在設計高吞吐、低延遲的 Agent 系統時,有三大效能法則值得工程師牢記:
第一,善用提示詞快取(Prompt Caching):現代模型供應商普遍支援提示詞快取技術。如果你的 system prompt 與前置知識庫內容保持完全不變且達到特定長度門檻(如 1,024 Tokens),重複的呼叫可以享有顯著的費用折扣與極低的推理延遲(實際計價與快取命中規範請以官方文件為準)。因此,務必將靜態設定放在訊息最前端,切忌在 system prompt 中頻繁插入即時變動的時間戳記,以免快取頻繁失效。
第二,非同步 I/O 與平行連線池:Agent 往往需要同時對多個模型發出推理請求或並發檢索資料。使用 httpx.AsyncClient 配合連線池管理,能讓單一工作行程在等待模型生成 Tokens 的 I/O 空檔中同時處理其他請求,大幅提升伺服器單機並發量。
第三,傳輸酬載(Payload)的深拷貝安全性:在多執行緒或圖排程環境中,歷史訊息陣列容易被多個模組共享存取。若在修剪或追加訊息時直接操作原始串列,容易造成競爭條件(Race Condition)或狀態汙染。在對話歷史進出關鍵節點時,養成使用乾淨複製本的習慣是避免幽靈 Bug 的良方。
小結
今天我們完成了 AI Agent 底層通訊協定的深度解構。我們釐清了 system、user、assistant、tool 四大角色的核心職責,實作了具備離線模擬與安全降級機制的 LLMClient,並打造出了能維持工具呼叫原子性的 prune_chat_history() 修剪演算法。這些底層積木將為我們後續建立自動化迴圈與圖狀態機提供堅實的傳輸保證。
以下整理今天所涉及的重要工程術語與概念對照:
- 系統提示詞(System Prompt):賦予模型全局約束、角色定義與行為邊界的最高權威性訊息。
- 上下文視窗(Context Window):大型語言模型在單次請求中所能接收與處理的最大 Token 總量限制。
- 原子性完整(Atomic Integrity):確保模型工具呼叫要求與工具執行回覆在修剪時始終成對保留的特性。
- 提示詞快取(Prompt Caching):利用前綴文本的一致性重用模型預運算快取,進而降低延遲與成本的技術。
- 滑動視窗(Sliding Window):在維持最新記憶的同時,自動丟棄最久遠歷史以控制總長度的佇列策略。
結語
掌握了對話 API 的角色分配與歷史長度控制後,下一步就是為我們的研究助理注入「專業靈魂」。光有通訊管線還不夠,模型需要一套高度工業化、規範清晰且具備少樣本引導的提示詞體系,才能在開放式的研究課題中穩定輸出符合預期的思考脈絡。
明天,我們會進入「AG Day 4 Prompt 設計:角色、少樣本與輸出約束」,深入探討如何透過明確的角色定位、精準的少樣本示範(Few-shot Examples)與嚴格的負面約束,防止模型在研究過程中產生幻覺,打造出專業級的結構化提示詞樣板!
延伸資源
- OpenAI 官方文件:Chat Completions API Guide。深入了解 messages 結構與 tool_calls 規範。
- Anthropic 官方技術指南:Prompt Engineering Interactive Tutorial。掌握系統提示詞設計與提示詞快取最佳實務。
- Tiktoken 官方開源庫:
https://github.com/openai/tiktoken。精確計算與統計訊息 Token 數量的專用工具。 - Pydantic 官方文件:Data Validation and Settings Management。型別安全與契約定義的業界首選。
留言
張貼留言