Danh sách bài viết

Bài 44: Workflow — LLM chọn tool, app thực thi, trả kết quả về

Bài 43 đã trình bày tool schema. Bài này nối tiếp bằng workflow đầy đủ: từ user query đến câu trả lời cuối qua 6 bước, trong đó LLM không bao giờ trực tiếp gọi tool — nó chỉ trả về một JSON mô tả tool call, app là bên thực thi và gửi result ngược lại. Bài đi qua diagram, code OpenAI (tool_calls + message role: "tool") và Anthropic (block tool_use + tool_result), multi-turn loop, conversation state, stop reason, tool_choice ép tool, streaming tool delta, debug log, abstraction LangChain AgentExecutor và state persistence.

25/05/2026
14 phút đọc
1 lượt xem
1

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, message role: "tool") và Anthropic (tool_use block, tool_result block).
  • 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 AgentExecutor gó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.

2

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.

3

6 bước của workflow

  1. User query — app nhận input, đẩy vào messages với role: "user", gửi cùng tools schema lên LLM.
  2. LLM decide — model đọc query + tools, quyết định trả lời thẳng (text) hay gọi tool.
  3. 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ặc stop_reason = "tool_use" (Anthropic).
  4. App execute — app parse arguments, validate, gọi function thật (HTTP, DB, math), nhận result.
  5. Return result — app append assistant message gốc và một message mới chứa result (OpenAI role: "tool", Anthropic block tool_result) rồi gọi LLM lần 2.
  6. 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.

4

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.

5

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.

6

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"msg.content có text, msg.tool_calls is None — model trả lời thẳng, không cần tool. App in msg.content cho user. Kết thúc.
  • finish_reason == "tool_calls"msg.tool_calls là 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.

7

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:

  • idtool_call_id sẽ 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ải json.loads trướ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).

8

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 name nằ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ùng pydantic validate 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).
  • timeout riêng — một tool slow không treo cả request.
9

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) 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)},
    ],
})
10

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_calls nữ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.

11

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.

12

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.

13

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 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.

14

Conversation state — messages list

messages sau workflow một câu hỏi điển hình có 4 entry:

  1. {"role": "user", "content": "..."} — query.
  2. {"role": "assistant", "content": None, "tool_calls": [...]} — model decide gọi tool.
  3. {"role": "tool", "tool_call_id": "...", "content": "..."} — result.
  4. {"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.

15

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ạm max_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 OpenAI stop.
  • tool_use — tương ứng tool_calls.
  • max_tokens — tương ứng length.
  • 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.

16

Tool result format — string hoặc JSON string

content của tool result phải là string. Hai cách phổ biến:

  • JSON stringjson.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.

17

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.

18

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.

19

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.

20

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.

21

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.

22

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.

23

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.

24

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; @tool decorator tự sinh schema từ docstring và type hint.
  • Callback hookon_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.

25

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.

26

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".

27

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.

28

Bài tập

  1. 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 trong messages sau khi kết thúc, xác nhận: user → assistant(tool_calls) → tool → assistant(content). Verify finish_reason ở mỗi turn.
  2. 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.
  3. Build multi-turn dependency: tool search_blog(query) trả list 3 blog_id, tool get_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).
  4. Thêm debug log như section 23: log JSON từng tool call với tool_call_id, name, args, duration. Format extra dùng python-json-logger để output JSON line dễ grep.
  5. 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.
  6. (Tuỳ chọn) Convert workflow sang LangChain AgentExecutor. So sánh số dòng code, đo overhead per turn.