Mục lục
- Mục tiêu bài học
- Vì sao cần workflow rõ ràng
- 6 bước của workflow
- ASCII diagram
- Bước 1 — user query và message khởi tạo
- Bước 2 — LLM decide gọi tool hay trả lời
- Bước 3 — LLM sinh tool call JSON
- Bước 4 — app validate và execute
- Bước 5 — gửi tool result về LLM
- Bước 6 — LLM synthesize câu trả lời
- OpenAI full workflow code
- Anthropic full workflow code
- Multi-turn loop
- Conversation state — messages list
- Stop reason — biết khi nào dừng
- Tool result format — string hoặc JSON string
- Error handling preview (Bài 46)
- Streaming kết hợp tool calling
- Force tool use — tool_choice
- Parallel tool calls preview (Bài 45)
- Sequential dependency
- Cost tracking — đếm round trip
- Debugging — log mỗi tool call
- LangChain abstraction — AgentExecutor
- State persistence — DB và Redis
- Token efficiency — trim và summarize
- Code Python — workflow đầy đủ
- Bài tập
Mục tiêu bài học
Sau bài này, bạn cần nắm được:
- 6 bước của workflow Function Calling: user query → LLM decide → tool call → app execute → tool result → final answer.
- LLM không tự thực thi tool — chỉ sinh JSON tool call. App backend mới là bên gọi function thật.
- Cách build full loop OpenAI (
tool_calls, messagerole: "tool") và Anthropic (tool_useblock,tool_resultblock). - Khi nào loop tiếp, khi nào dừng — dựa trên
finish_reason/stop_reason. - Cách ép model dùng tool cụ thể với
tool_choice. - Token cost tăng tuyến tính theo số turn — vì sao cần trim history.
- LangChain
AgentExecutorgói loop này thành một call duy nhất; hiểu skeleton bên dưới để debug.
Bài này là bài "ráp mảnh": Bài 43 dạy schema, bài này dạy cách dùng schema đó trong vòng đời 1 request. Bài 45 sẽ mở rộng sang parallel tool calls, Bài 46 sang error handling.
Vì sao cần workflow rõ ràng
Người mới hay bị lẫn vì SDK ẩn nhiều thứ. Hai hiểu nhầm phổ biến:
- "LLM gọi API bên ngoài" — sai. LLM chạy trên server của OpenAI/Anthropic, không có quyền network ra ngoài. Nó chỉ sinh text: text này có cấu trúc đặc biệt (JSON) để app parse thành lời gọi function.
- "Một call SDK xong là có answer" — sai khi có tool. Một câu hỏi cần tool ít nhất tốn 2 lần gọi LLM: lần đầu để model decide tool, lần thứ hai để model nhìn result và synthesize.
Trách nhiệm phân chia rõ:
- LLM: decide có gọi tool không, gọi tool nào, với arg gì; sau khi nhận result thì synthesize câu trả lời tự nhiên.
- App backend: nhận tool call, validate, execute function thật, gửi result ngược lại.
Hiểu sai sẽ dẫn đến code không tự loop, không append result đúng chỗ, hoặc gửi result với format sai gây 400 từ API.
6 bước của workflow
- User query — app nhận input, đẩy vào
messagesvớirole: "user", gửi cùngtoolsschema lên LLM. - LLM decide — model đọc query + tools, quyết định trả lời thẳng (text) hay gọi tool.
- LLM generate tool call — nếu cần tool, model phát ra JSON với tên tool và arguments. Field
finish_reason = "tool_calls"(OpenAI) hoặcstop_reason = "tool_use"(Anthropic). - App execute — app parse arguments, validate, gọi function thật (HTTP, DB, math), nhận result.
- Return result — app append assistant message gốc và một message mới chứa result (OpenAI
role: "tool", Anthropic blocktool_result) rồi gọi LLM lần 2. - LLM synthesize — model nhận đủ data, viết câu trả lời tự nhiên cho user.
Nếu model cần nhiều tool sequential (ví dụ search rồi summarize), lặp lại bước 3-5 thêm vòng. Đó là multi-turn loop ở bước 13.
ASCII diagram
┌─────────┐ ┌───────────┐
│ USER │ │ LLM │
└────┬────┘ └─────┬─────┘
│ 1. query + tools schema │
│ ─────────────────────────────────────────► │
│ │
│ │ 2. decide
│ │
│ 3. response: tool_calls │
│ ◄───────────────────────────────────────── │
│ │
│ 4. execute tool (HTTP/DB/math) │
│ │
│ 5. messages + tool_result │
│ ─────────────────────────────────────────► │
│ │
│ │ 6. synthesize
│ │
│ final answer (role: assistant, text) │
│ ◄───────────────────────────────────────── │
│ │
Hai mũi tên đi ra phải, hai mũi tên đi vào trái — đó là 2 LLM call tối thiểu cho mọi câu hỏi cần tool. Nếu sequential nhiều tool, vòng 3-5 sẽ lặp thêm.
Bước 1 — user query và message khởi tạo
App build messages với optional system prompt và user input:
messages = [
{"role": "system", "content": "Bạn là trợ lý du lịch. Dùng tool khi cần dữ liệu real-time."},
{"role": "user", "content": "Thời tiết Hà Nội bây giờ thế nào?"},
]
tools = [weather_tool] # schema đã build ở Bài 43
System prompt nên nêu rõ hai điều: khi nào dùng tool (ví dụ "khi user hỏi dữ liệu real-time") và khi nào trả lời thẳng (ví dụ "khi câu hỏi general knowledge"). Model nếu không có hướng dẫn sẽ tự đoán — đôi khi gọi tool dư hoặc thiếu.
Bước 2 — LLM decide gọi tool hay trả lời
App gọi chat.completions.create kèm tools:
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=tools,
)
msg = response.choices[0].message
finish_reason = response.choices[0].finish_reason
Hai outcome có thể xảy ra:
finish_reason == "stop"vàmsg.contentcó text,msg.tool_calls is None— model trả lời thẳng, không cần tool. App inmsg.contentcho user. Kết thúc.finish_reason == "tool_calls"vàmsg.tool_callslà list — model muốn gọi tool. Đi tiếp bước 3-5.
Model decide dựa vào: nội dung query, schema mô tả tool (name + description + parameters), system prompt, và message history. Description tool kém → model decide kém.
Bước 3 — LLM sinh tool call JSON
Cấu trúc tool_calls của OpenAI:
msg.tool_calls
# [
# ChatCompletionMessageToolCall(
# id="call_abc123",
# type="function",
# function=Function(
# name="get_weather",
# arguments='{"city":"Hanoi"}', # JSON STRING, không phải dict
# ),
# )
# ]
Ba field cần lấy:
id—tool_call_idsẽ dùng ở bước 5 để khớp result với call gốc.function.name— tên tool để app dispatch sang function thực thi.function.arguments— JSON string, app phảijson.loadstrước khi gọi function.
Anthropic tương đương dùng block tool_use trong response.content: {type: "tool_use", id, name, input}, với input đã là dict sẵn (không cần parse).
Bước 4 — app validate và execute
App dispatch tên tool sang function thực, parse argument, gọi:
import json
TOOL_REGISTRY = {
"get_weather": get_weather, # def get_weather(city: str) -> dict
"get_time": get_time,
}
def execute_tool(tool_call):
name = tool_call.function.name
args = json.loads(tool_call.function.arguments)
fn = TOOL_REGISTRY.get(name)
if fn is None:
return {"error": f"unknown tool: {name}"}
return fn(**args)
Vài lưu ý phải check ngay đây để tránh lỗi runtime:
- Validate
namenằm trong registry — model đôi khi hallucinate tên không tồn tại. - Validate keyword arg khớp signature — nếu thiếu/thừa key sẽ raise
TypeError. Có thể dùngpydanticvalidate args. - Wrap try/except — exception bên trong function thật phải convert thành error message gửi lại LLM, không raise lên trên (Bài 46 chi tiết).
- Có
timeoutriêng — một tool slow không treo cả request.
Bước 5 — gửi tool result về LLM
Đây là chỗ hay sai. Phải append cả hai: assistant message gốc (có tool_calls) và message tool result. Thiếu một trong hai, API raise 400.
messages.append(msg) # assistant message với tool_calls
for tc in msg.tool_calls:
result = execute_tool(tc)
messages.append({
"role": "tool",
"tool_call_id": tc.id, # khớp với id của call
"content": json.dumps(result), # string
})
Sau khi append, app gọi lại create với messages mới. Model có đủ context: query, tool đã gọi, result. Nó sẽ synthesize ở bước 6.
Anthropic dùng pattern khác: tool result đóng gói thành content của một message role: "user" mới (không có role "tool" riêng). Block code:
messages.append({"role": "assistant", "content": response.content})
messages.append({
"role": "user",
"content": [
{"type": "tool_result",
"tool_use_id": "toolu_abc",
"content": json.dumps(result)},
],
})
Bước 6 — LLM synthesize câu trả lời
Call thứ hai trông y hệt call đầu, chỉ khác messages giờ có thêm hai entry:
response2 = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=tools,
)
msg2 = response2.choices[0].message
# msg2.content: "Thời tiết Hà Nội hiện tại 28°C, có mây..."
# msg2.tool_calls: None
# finish_reason: "stop"
Hai khả năng vẫn xảy ra:
- Model trả answer dạng text → in cho user, dừng.
- Model lại đẻ thêm
tool_callsnữa (cần tool tiếp) → loop tiếp. Đây là multi-turn ở bước 13.
Result tốt phụ thuộc nhiều vào format result: nếu app gửi JSON sạch (key có meaning), model trả lời tự nhiên hơn nhiều so với dump raw string.
OpenAI full workflow code
Ráp 6 bước thành một function chạy được — single-turn (1 tool):
import json
from openai import OpenAI
client = OpenAI()
def get_weather(city: str) -> dict:
# Giả lập HTTP request đến weather API
return {"city": city, "temp_c": 28, "condition": "partly cloudy"}
weather_tool = {
"type": "function",
"function": {
"name": "get_weather",
"description": "Lấy thời tiết hiện tại của một thành phố",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
},
}
TOOL_REGISTRY = {"get_weather": get_weather}
def chat_with_tool(user_input: str):
messages = [{"role": "user", "content": user_input}]
tools = [weather_tool]
# Bước 2: gọi LLM lần 1
r1 = client.chat.completions.create(
model="gpt-4o-mini", messages=messages, tools=tools,
)
msg = r1.choices[0].message
if not msg.tool_calls:
return msg.content # model trả thẳng, dừng
# Bước 5: append assistant message và tool result
messages.append(msg)
for tc in msg.tool_calls:
args = json.loads(tc.function.arguments)
result = TOOL_REGISTRY[tc.function.name](**args)
messages.append({
"role": "tool",
"tool_call_id": tc.id,
"content": json.dumps(result),
})
# Bước 6: gọi LLM lần 2 để synthesize
r2 = client.chat.completions.create(
model="gpt-4o-mini", messages=messages, tools=tools,
)
return r2.choices[0].message.content
print(chat_with_tool("Thời tiết Hà Nội bây giờ?"))
30 dòng code. Đây là baseline mọi feature tool calling khác (parallel, multi-turn, error handling) đều extend từ đây.
Anthropic full workflow code
Khác biệt chính: tool result đóng gói trong message role: "user", không có role tool. Stop reason là "tool_use" thay vì "tool_calls".
import json
import anthropic
client = anthropic.Anthropic()
weather_tool = {
"name": "get_weather",
"description": "Lấy thời tiết hiện tại của một thành phố",
"input_schema": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
}
def chat_with_tool(user_input: str):
messages = [{"role": "user", "content": user_input}]
tools = [weather_tool]
r1 = client.messages.create(
model="claude-opus-4",
max_tokens=1024,
tools=tools,
messages=messages,
)
if r1.stop_reason != "tool_use":
# Trả thẳng text
return "".join(b.text for b in r1.content if b.type == "text")
# Append assistant message (giữ nguyên content array)
messages.append({"role": "assistant", "content": r1.content})
# Tạo tool_result cho từng block tool_use
tool_results = []
for block in r1.content:
if block.type != "tool_use":
continue
result = get_weather(**block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": json.dumps(result),
})
# Tool result đi trong message user
messages.append({"role": "user", "content": tool_results})
r2 = client.messages.create(
model="claude-opus-4",
max_tokens=1024,
tools=tools,
messages=messages,
)
return "".join(b.text for b in r2.content if b.type == "text")
Khác biệt cần nhớ: Anthropic content luôn là array of blocks; phải lọc type trước khi đọc text hay input.
Multi-turn loop
Khi task cần nhiều tool sequential (turn sau phụ thuộc turn trước), wrap thành vòng while:
def run_agent(user_input, max_steps=10):
messages = [{"role": "user", "content": user_input}]
for step in range(max_steps):
r = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=tools,
)
msg = r.choices[0].message
messages.append(msg)
if not msg.tool_calls:
return msg.content # final answer
for tc in msg.tool_calls:
args = json.loads(tc.function.arguments)
result = TOOL_REGISTRY[tc.function.name](**args)
messages.append({
"role": "tool",
"tool_call_id": tc.id,
"content": json.dumps(result),
})
raise RuntimeError("max_steps exceeded")
Ba điểm cần có:
max_steps— chặn vô tận nếu model loop nhầm. Production thường 5-15.- Condition dừng — không có
tool_calls→ in answer và return. - Append đủ đôi — assistant message và tool result trong cùng iteration.
Mỗi vòng for là một turn. Sequential nhiều bước = nhiều turn = nhiều LLM call. Bài 45 sẽ trình bày parallel để giảm số turn khi tool độc lập.
Conversation state — messages list
messages sau workflow một câu hỏi điển hình có 4 entry:
{"role": "user", "content": "..."}— query.{"role": "assistant", "content": None, "tool_calls": [...]}— model decide gọi tool.{"role": "tool", "tool_call_id": "...", "content": "..."}— result.{"role": "assistant", "content": "..."}— final answer.
Cần lưu cả 4 nếu muốn user hỏi follow-up. Lần follow-up tiếp theo, model thấy hết history nên hiểu context.
Nếu app stateless (không lưu DB), mỗi message gửi lên phải gồm full history — token đầu vào tăng theo từng turn. Đây là một lý do bài 26 đề cập đến trim/summarize.
Stop reason — biết khi nào dừng
OpenAI finish_reason có các giá trị chính:
stop— model dừng tự nhiên (đã trả lời xong, hoặc gặp stop sequence).tool_calls— model muốn gọi tool, app phải execute và gửi result.length— chạmmax_tokens, output bị cắt. App nên log cảnh báo.content_filter— vi phạm chính sách, output bị chặn.
Anthropic stop_reason:
end_turn— tương ứng OpenAIstop.tool_use— tương ứngtool_calls.max_tokens— tương ứnglength.stop_sequence— gặp stop sequence custom.
App nên branch theo stop reason: tool_use → execute và loop; max_tokens → log warning, có thể retry với max_tokens lớn hơn; content_filter → return error gracefully.
Tool result format — string hoặc JSON string
content của tool result phải là string. Hai cách phổ biến:
- JSON string —
json.dumps(result). Phù hợp khi result là dict/list. Model parse được structure rõ, dễ trích trường cụ thể trong final answer. - Plain string — natural language thẳng. Phù hợp khi result đơn giản (một dòng), hoặc khi muốn model copy y nguyên (ví dụ summary đã pre-formatted).
Khuyến nghị mặc định: JSON string với key có meaning. Tránh:
- Dump
str(obj)raw — model phải đoán format, dễ lỗi. - Trả về cả binary (image, PDF) — phải convert sang base64 hoặc URL.
- Result quá dài (> 5k token) — gây cost cao và model "loãng" attention. Truncate hoặc summarize trước khi trả.
Result tốt giúp model trả lời tự nhiên hơn vì có structure để bám vào.
Error handling preview (Bài 46)
Tool có thể fail vì nhiều lý do: API 500, timeout, validation, schema invalid. Nguyên tắc: không raise exception ra ngoài loop. Convert thành error message gửi lại LLM:
def execute_tool(tool_call):
try:
args = json.loads(tool_call.function.arguments)
fn = TOOL_REGISTRY[tool_call.function.name]
return fn(**args)
except KeyError:
return {"error": f"Tool không tồn tại: {tool_call.function.name}"}
except json.JSONDecodeError as e:
return {"error": f"Arguments không phải JSON hợp lệ: {e}"}
except Exception as e:
return {"error": f"Tool execution failed: {e}"}
Model nhận error message → có thể retry với args khác, hoặc gửi cho user thông báo có vấn đề. Nếu app raise lên trên, loop chết, user nhận 500. Bài 46 sẽ đi sâu retry/backoff, classify error, fail-safe pattern.
Streaming kết hợp tool calling
Stream với tool calling phức tạp hơn stream text vì tool_calls đi từng phần. OpenAI gửi delta dạng:
stream = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=tools,
stream=True,
)
tool_calls_acc = {} # index -> partial tool call
for chunk in stream:
delta = chunk.choices[0].delta
if delta.tool_calls:
for tc_delta in delta.tool_calls:
idx = tc_delta.index
if idx not in tool_calls_acc:
tool_calls_acc[idx] = {
"id": tc_delta.id or "",
"name": "",
"arguments": "",
}
if tc_delta.function:
if tc_delta.function.name:
tool_calls_acc[idx]["name"] += tc_delta.function.name
if tc_delta.function.arguments:
tool_calls_acc[idx]["arguments"] += tc_delta.function.arguments
# Cuối stream, tool_calls_acc chứa đủ tool calls
# arguments là JSON string đã ghép xong → json.loads để dùng
Lý do: arguments là JSON string dài, server stream chunk theo token. App phải ghép trước khi parse. Anthropic gửi event input_json_delta tương tự.
Streaming hữu ích khi muốn show "đang gọi tool X..." cho user ngay, không phải chờ full response. Trade-off: code phức tạp hơn — production nhiều team chỉ stream text final, không stream tool call.
Force tool use — tool_choice
Param tool_choice kiểm soát hành vi decide ở bước 2:
"auto"(default) — model tự decide."none"— cấm gọi tool, bắt trả text."required"— bắt buộc gọi ít nhất một tool nào đó.{"type": "function", "function": {"name": "get_weather"}}— ép gọi đúng tool này.
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=tools,
tool_choice={"type": "function",
"function": {"name": "get_weather"}},
)
Anthropic tương đương: tool_choice={"type": "tool", "name": "get_weather"}; {"type": "any"} tương đương required; {"type": "auto"} mặc định.
Khi dùng: validate input (bắt model classify trước khi reply), workflow cố định ("luôn search trước"), test debug để xem model build args ra sao.
Parallel tool calls preview (Bài 45)
Một response từ LLM có thể chứa nhiều tool calls — gpt-4o, gpt-4-turbo bật parallel mặc định:
msg.tool_calls
# [
# ToolCall(id="c1", function=Function(name="get_weather", arguments='{"city":"Hanoi"}')),
# ToolCall(id="c2", function=Function(name="get_weather", arguments='{"city":"Tokyo"}')),
# ]
App có thể execute song song để giảm latency. Quan trọng: append tool result cho TẤT CẢ tool calls trước khi gọi LLM lần kế tiếp. Thiếu một result, API raise 400.
Bài 45 đi chi tiết: parallel khi nào work, asyncio.gather, ThreadPoolExecutor, race condition, idempotent tool.
Sequential dependency
Ngược với parallel: tool 2 cần result tool 1. Ví dụ:
- User: "Tìm bài blog mới nhất về RAG, rồi tóm tắt nó."
- Turn 1: model gọi
search_blog("RAG"). App trả list bài → append result. - Turn 2: model thấy list, gọi
summarize(blog_id="abc123"). App trả summary. - Turn 3: model viết answer cuối cho user.
Đây là sequential — multi-turn loop bắt buộc. Mỗi turn = 1 LLM call mới. Không có cách parallel hoá vì arg blog_id chỉ có sau khi search xong.
Latency cộng dồn: 3 turn × (LLM 0.5s + tool 1s) = 4.5s. Đây là chi phí của workflow dependency.
Cost tracking — đếm round trip
Mỗi turn = 1 LLM call = 1 lần tính tiền theo token. Vì messages tích luỹ qua các turn:
- Turn 1: input = system + user query + tools schema. Output = tool_calls JSON.
- Turn 2: input = turn 1 input + assistant msg + tool result. Output = (có thể) tool_calls tiếp hoặc final answer.
- Turn 3: input = turn 2 input + ... — tăng dần.
Tổng input token gần như là tam giác tăng tuyến tính. Một agent loop 10 turn dễ tốn 5-10x token so với single-turn. Đếm cost trong production:
total_input = 0
total_output = 0
for step in range(max_steps):
r = client.chat.completions.create(...)
total_input += r.usage.prompt_tokens
total_output += r.usage.completion_tokens
...
print(f"Cost = ${total_input * 0.15/1e6 + total_output * 0.6/1e6}")
# số tham khảo cho gpt-4o-mini, check pricing thực tế khi chạy
Log usage mỗi turn giúp dashboard cost theo user/feature. Phát hiện loop quá dài, prompt blow up, tool result quá to.
Debugging — log mỗi tool call
Agent loop dễ sai ở những điểm: model gọi tool sai tên, args sai format, loop quá nhiều turn, tool fail không catch. Log telemetry tối thiểu:
import logging, time
logger = logging.getLogger("agent")
for tc in msg.tool_calls:
start = time.perf_counter()
args = json.loads(tc.function.arguments)
try:
result = TOOL_REGISTRY[tc.function.name](**args)
status = "ok"
except Exception as e:
result = {"error": str(e)}
status = "error"
duration = time.perf_counter() - start
logger.info(
"tool_call",
extra={
"turn": step,
"tool_call_id": tc.id,
"tool_name": tc.function.name,
"args": args,
"status": status,
"duration_ms": duration * 1000,
"result_size": len(json.dumps(result)),
},
)
Đẩy log sang structured logging (Loki, Datadog, Honeycomb) → query: "show all failing tool calls last 24h", "p95 latency của get_weather", "top user gọi nhiều turn nhất". Telemetry là điều kiện cần để debug agent thật.
LangChain abstraction — AgentExecutor
LangChain gói loop ở bước 13 thành hai bước build:
from langchain_openai import ChatOpenAI
from langchain.agents import create_tool_calling_agent, AgentExecutor
from langchain_core.tools import tool
from langchain_core.prompts import ChatPromptTemplate
@tool
def get_weather(city: str) -> dict:
"""Lấy thời tiết hiện tại của một thành phố."""
return {"city": city, "temp_c": 28}
llm = ChatOpenAI(model="gpt-4o-mini")
prompt = ChatPromptTemplate.from_messages([
("system", "Bạn là trợ lý du lịch."),
("human", "{input}"),
("placeholder", "{agent_scratchpad}"),
])
agent = create_tool_calling_agent(llm, [get_weather], prompt)
executor = AgentExecutor(agent=agent, tools=[get_weather], max_iterations=10)
print(executor.invoke({"input": "Thời tiết Hà Nội và Tokyo?"}))
Bên trong, LangChain build cùng loop: gọi LLM, đọc tool_calls, dispatch, append result, gọi lại. Hai lợi ích chính:
- Tự động — không phải viết loop tay;
@tooldecorator tự sinh schema từ docstring và type hint. - Callback hook —
on_tool_start,on_tool_end,on_llm_errorđể cắm logging/tracing (LangSmith) mà không phải sửa loop.
Trade-off: abstraction che chi tiết — khi loop sai (ví dụ model loop vô hạn), khó debug nếu không hiểu skeleton bên dưới. Bài 13 này là chìa khoá hiểu LangChain.
State persistence — DB và Redis
Production app cần resume conversation sau khi user đóng browser, hoặc cân bằng tải qua nhiều server. messages phải lưu external:
- Postgres / MongoDB — bảng
conversations(id, user_id, messages_json, updated_at). Đọc khi user mở chat, ghi sau mỗi turn. - Redis — key
conv:{user_id}:{conv_id}trỏ JSON. TTL cho session ngắn, persist sang DB khi quan trọng. - LangGraph checkpointer — built-in API lưu/khôi phục state agent giữa các step.
Một detail dễ quên: assistant message với tool_calls phải serialize đầy đủ (id, name, arguments). Load lại nếu thiếu, lần gọi tiếp sẽ raise 400 vì API thấy tool_call orphan (không có tool result đi kèm).
Workflow chuẩn: save messages sau MỖI turn (không chỉ ở cuối), để khi crash giữa chừng vẫn resume được.
Token efficiency — trim và summarize
Sau 10-20 turn, messages phồng to. Ba kỹ thuật giảm token:
- Trim theo cửa sổ — giữ N turn gần nhất + system prompt. Đơn giản nhất, mất context xa.
- Summarize — định kỳ gọi LLM (model rẻ) tóm tắt phần cũ thành 1 paragraph, thay thế các turn cũ. Giữ ý chính, mất chi tiết.
- Concise tool result — sau khi model dùng xong tool result để synthesize, có thể replace content tool result bằng "[trimmed]" để giảm token các turn sau. Vẫn giữ tool_call_id để cấu trúc đúng.
def trim_old_tool_results(messages, keep_last_n=3):
"""Replace tool result content của các turn cũ bằng placeholder."""
tool_turns = [i for i, m in enumerate(messages)
if m.get("role") == "tool"]
for idx in tool_turns[:-keep_last_n]:
messages[idx]["content"] = "[trimmed]"
return messages
Hai chỉ tiêu cần monitor: số token mỗi turn (xem trend) và recall accuracy (test bộ câu follow-up về context cũ). Trim quá mạnh thì model "quên".
Code Python — workflow đầy đủ
Gói multi-turn loop OpenAI có log, error handling, max_steps, usage tracking:
import json, time, logging
from openai import OpenAI
logger = logging.getLogger("agent")
logging.basicConfig(level=logging.INFO)
client = OpenAI()
def get_weather(city: str) -> dict:
return {"city": city, "temp_c": 28, "condition": "partly cloudy"}
def get_time(timezone: str) -> dict:
return {"timezone": timezone, "now": "2026-05-25T10:00:00"}
TOOL_REGISTRY = {"get_weather": get_weather, "get_time": get_time}
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Lấy thời tiết hiện tại của một thành phố",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
},
},
{
"type": "function",
"function": {
"name": "get_time",
"description": "Lấy giờ hiện tại theo timezone (ví dụ Asia/Tokyo)",
"parameters": {
"type": "object",
"properties": {"timezone": {"type": "string"}},
"required": ["timezone"],
},
},
},
]
def execute_tool(tool_call):
name = tool_call.function.name
try:
args = json.loads(tool_call.function.arguments)
fn = TOOL_REGISTRY.get(name)
if fn is None:
return {"error": f"unknown tool: {name}"}
return fn(**args)
except Exception as e:
return {"error": f"{type(e).__name__}: {e}"}
def run_agent(user_input: str, max_steps: int = 10):
messages = [
{"role": "system",
"content": "Trợ lý du lịch. Gọi tool khi cần dữ liệu real-time."},
{"role": "user", "content": user_input},
]
usage_total = {"prompt": 0, "completion": 0}
for step in range(max_steps):
r = client.chat.completions.create(
model="gpt-4o-mini", messages=messages, tools=tools,
)
msg = r.choices[0].message
usage_total["prompt"] += r.usage.prompt_tokens
usage_total["completion"] += r.usage.completion_tokens
messages.append(msg)
if not msg.tool_calls:
logger.info("done", extra={"step": step, "usage": usage_total})
return msg.content, usage_total
for tc in msg.tool_calls:
t0 = time.perf_counter()
result = execute_tool(tc)
dt = (time.perf_counter() - t0) * 1000
logger.info(
"tool_call",
extra={"step": step, "name": tc.function.name,
"duration_ms": dt, "ok": "error" not in result},
)
messages.append({
"role": "tool",
"tool_call_id": tc.id,
"content": json.dumps(result),
})
raise RuntimeError("max_steps exceeded")
answer, usage = run_agent("Thời tiết Hà Nội bây giờ và giờ hiện tại ở Tokyo?")
print(answer)
print(usage)
Code 80 dòng, dùng được cho task vừa. Sản phẩm thực tế thêm: retry/backoff, semaphore concurrency, persist messages DB, observability stack.
Bài tập
- Implement workflow đầy đủ với 1 tool
get_weather(city)(mock return dict). Hỏi "Thời tiết Hà Nội bây giờ?". In ra 4 entry trongmessagessau khi kết thúc, xác nhận: user → assistant(tool_calls) → tool → assistant(content). Verifyfinish_reasonở mỗi turn. - Thêm 2 tool nữa:
get_time(timezone),currency_rate(from, to). Hỏi "Giờ ở Tokyo bây giờ và 1 USD bằng bao nhiêu VND?". Quan sát model có gọi parallel (2 tool trong 1 response) hay không. Log số turn. - Build multi-turn dependency: tool
search_blog(query)trả list 3 blog_id, toolget_blog(blog_id)trả content. Hỏi "Tìm blog về RAG mới nhất rồi tóm tắt.". Đếm số turn (kỳ vọng 3: search → get_blog → synthesize). - Thêm debug log như section 23: log JSON từng tool call với
tool_call_id, name, args, duration. Formatextradùngpython-json-loggerđể output JSON line dễ grep. - Implement
trim_old_tool_results(messages, keep_last_n=3). Chạy agent với 10 turn nhân tạo (tool gọi liên tục, dữ liệu giả lập), so sánh tổng token với và không trim. - (Tuỳ chọn) Convert workflow sang LangChain
AgentExecutor. So sánh số dòng code, đo overhead per turn.
- OpenAI — Function calling guide
- OpenAI — Chat completions API reference (tool_choice, tools)
- Anthropic — Tool use overview
- Anthropic — Implement tool use
- OpenAI — Streaming tool calls
- LangChain — Build an agent with AgentExecutor
- LangChain — create_tool_calling_agent reference
- LangGraph — Persistence and checkpointers
- Python docs — json module
- Python docs — logging
