Multi-turn Conversations: Correctly Passing Tool Call History
End-to-end practice—from knowledge base creation to multi-turn API calls—demonstrating how to pass tool call history to avoid redundant retrieval
This guide demonstrates how to pass tool call history across multi-turn conversations, enabling the model to answer continuously based on prior retrieval results and avoid redundant searches. All steps use the same sample document set for reproducible results.
RAG Agents are stateless services: each request is independent, and the server does not retain conversation context. A single response typically involves multiple stages:
Copy
User Query ──→ Reasoning ──→ Tool Call for Retrieval ──→ Retrieval Results ──→ Sufficient Information? ▲ │ └────────────────────────── No ──────────────────────────────────┤ │ Yes ▼ Final Answer Generation
If only textual history (user query + assistant reply) is passed in the next turn, the model cannot see intermediate tool calls or know what has already been retrieved—leading to redundant retrieval of the same question. This wastes tokens and may yield inconsistent answers.The correct approach is to include the full tool call chain (assistant.tool_calls + corresponding tool return results) as part of the message history, allowing the model to build upon existing retrieval results.
This guide uses the Bailian Technical Documentation Sample, containing 92 official technical documents from the Bailian platform (API usage, model descriptions, best practices, etc.). Download, extract, and upload them to your knowledge base to begin. For demonstration, we’ll use the document “First-Time Calling of Qwen API” to illustrate multi-turn follow-up:
Turn 1: “How do I call the Qwen API?” (triggers multi-step tool-based retrieval)
Turn 2: “How do I configure my API Key as an environment variable?” (verifies whether history is reused and redundant retrieval is avoided)
You may also use your own documents—the steps and outcomes remain similar.
Use the retrieved parameters to make your first request. The request structure is as follows:
Copy
curl -X POST 'https://{workspace_id}.cn-beijing.maas.aliyuncs.com/api/v2/apps/knowledge/chat' \ -H 'Authorization: Bearer $API_KEY' \ -H 'Content-Type: application/json' \ -d '{ "input": { "messages": [{"role": "user", "content": "How do I call the Qwen API?"}] }, "parameters": { "agent_options": {"agent_id": "aid-xxxxxxxx"} }, "stream": true }'
Internally, the Agent executes multi-step tool calls to retrieve from the knowledge base and returns the final answer via streaming. You must extract the tool call history from the SSE stream—see Extracting Tool History from SSE Stream below.
For the second query—“How do I configure my API Key as an environment variable?”—pass the full history from Turn 1, including the tool call chain:
Copy
curl -X POST 'https://{workspace_id}.cn-beijing.maas.aliyuncs.com/api/v2/apps/knowledge/chat' \ -H 'Authorization: Bearer $API_KEY' \ -H 'Content-Type: application/json' \ -d '{ "input": { "messages": [ {"role": "user", "content": "How do I call the Qwen API?"}, {"role": "assistant", "content": "", "tool_calls": [{"id":"call_956...","function":{"name":"semantic_search",...}}]}, {"role": "tool", "tool_call_id": "call_956...", "content": "Retrieved 5 relevant chunks..."}, {"role": "assistant", "content": "Based on the document \"First-Time Calling of Qwen API\"..."}, {"role": "user", "content": "How do I configure my API Key as an environment variable?"} ] }, "parameters": {"agent_options": {"agent_id": "aid-xxxxxxxx"}}, "stream": true }'
The tool_call_id must match exactly the value returned in the first turn’s streaming response—not generated manually. The model uses it to associate each tool result with its corresponding call.
Using the “First-Time Calling of Qwen API” document as a test case: After Turn 1 (“How do I call the Qwen API?”), Turn 2 asks “How do I configure my API Key as an environment variable?” Here's how two history-passing strategies compare:
Inconsistent — new retrieval may yield different chunks
Real-world measurement: Turn 1 executed 2 tool calls (semantic_search → obtain_file) generating 6 history messages. When Turn 2 passed full history (7 messages), the model answered directly with 0 tool calls. With text-only history (3 messages), it triggered 2 new semantic_search calls. Exact round counts and message numbers depend on document structure and query matching—but the conclusion holds reliably: passing full history avoids redundant retrieval.
Passing full history increases input length (including tool outputs), but eliminates retrieval overhead. Text-only history yields shorter inputs yet incurs full retrieval latency. While trade-offs exist, passing full history delivers superior speed and consistency for multi-turn follow-ups.
Agent streaming uses Server-Sent Events (SSE). Each data frame is data:{...}, and the relevant path is obj.output.choices[0].message. The field message.extra.step_change marks state transitions—focus only on three event types:
Event
Identification Condition
Data to Extract
Tool Call
step_change == "tool_calling"
tool_calls array (all parallel calls in this round)
Extracted history messages must be ordered by time and conform to these roles and fields:
Role
Required Fields
Description
user
role, content
User input
assistant (tool call)
role, content (empty), tool_calls
All parallel tool calls in this round; function.arguments is a JSON string
tool
role, content, tool_call_id
Tool result; tool_call_id must match tool_calls[].id
assistant (final answer)
role, content
Model’s final answer
Messages must be strictly time-ordered:
Copy
user query ──→ assistant (tool_calls) ──→ tool × N ──→ assistant (tool_calls) ──→ tool × N ──→ assistant final answer ──→ user next turn
Multiple tool-call rounds repeat the assistant → tool pattern. Each new turn starts with a fresh user message and repeats the full flow—including all prior history.
The code below implements the full workflow: extracting history from SSE, auto-passing it across turns, and comparing outcomes. It’s tested against the sample documents and prompts used in this guide.
import jsonimport requests# Retrieved from the API Debugging page of your Q&A service in the consoleAPI_URL = "https://{workspace_id}.cn-beijing.maas.aliyuncs.com/api/v2/apps/knowledge/chat"API_KEY = "sk-xxxxxxxx" # Workspace-scoped API Key (starts with "sk-ws-")AGENT_ID = "aid-xxxxxxxx" # Knowledge Q&A service ID
Effect Comparison: Full History vs. Text-Only History
Copy
chat = MultiTurnChat()# Turn 1print("[Turn 1] How do I call the Qwen API?")chat.ask("How do I call the Qwen API?")# Turn 2 · Full tool history (auto-carried)print("\n[Turn 2 · Full tool history]")chat_full = MultiTurnChat()chat_full.history = list(chat.history)chat_full.ask("How do I configure my API Key as an environment variable?")# Turn 2 · Text-only history (drop tool calls, keep only user + assistant replies)print("\n[Turn 2 · Text-only history]")text_only = [m for m in chat.history if m["role"] == "user" or (m["role"] == "assistant" and not m.get("tool_calls"))]chat_text = MultiTurnChat()chat_text.history = text_onlychat_text.ask("How do I configure my API Key as an environment variable?")
Sample output:
Copy
[Turn 1] How do I call the Qwen API? Sent 1 message; 2 tool-call rounds[Turn 2 · Full tool history] Sent 7 messages; 0 tool-call rounds ← Reused; no redundant retrieval[Turn 2 · Text-only history] Sent 3 messages; 2 tool-call rounds ← Redundant retrieval
Tool return content can be lengthy; cumulative history may exceed context limits. Recommended mitigation strategies:
Truncate older tool returns: Keep full tool content only for the most recent K rounds; truncate earlier tool messages to first N characters
Sliding window: For histories >3 turns, replace earlier tool results with summaries
Copy
def truncate_history(messages, max_tool_len=2000, keep_recent=2): """Truncates tool return content from earlier rounds.""" user_indices = [i for i, m in enumerate(messages) if m["role"] == "user"] if len(user_indices) <= keep_recent: return messages cutoff = user_indices[-keep_recent] result = [] for i, msg in enumerate(messages): if i < cutoff and msg["role"] == "tool" and len(msg["content"]) > max_tool_len: msg = {**msg, "content": msg["content"][:max_tool_len] + "\n...(truncated)"} result.append(msg) return result
What happens if I omit tool call history?
The model loses retrieval context and redundantly re-searches identical queries—degrading answer quality. Real-world test: full history enables zero tool calls on follow-up; omission triggers full re-retrieval.Can I skip intermediate tool calls and send only the final answer?
It works functionally but is strongly discouraged. Sending only user + assistant(final answer) strips the model of memory about knowledge base content, significantly weakening follow-up performance.Does order matter for parallel tool returns?
Order doesn’t affect functionality—the model matches results via tool_call_id. However, all tool messages must appear immediately after their corresponding assistant(tool_calls) message.