Skip to content

Commit 400f512

Browse files
google-genai-botcopybara-github
authored andcommitted
perf: avoid deepcopy of session contents when building LLM requests
`_get_contents` deep-copied `event.content` for every event on every LLM request, just to strip ADK-generated (`adk-` prefixed) function call/response ids and to isolate the contents from downstream request processors that mutate parts in place (e.g. nl_planning clearing `part.thought`, code_execution rewriting parts). The deepcopy recursed into large `function_call.args`/`inline_data` payloads, and cost grew with conversation length (a dominant non-LLM CPU sink in profiling, ~4-7s of a ~30s run). Replace it with a shallow copy: the `Content` and every `Part` are `model_copy`-d (so downstream in-place mutations stay isolated from session events), but the payloads (`args`/`response`/`inline_data`/...) are shared by reference instead of deep-copied. Adds regression tests (id stripping and downstream-mutation isolation) and a google_benchmark perf script. Benchmark (_get_contents over a 500-turn history, ~23x): Before (copy.deepcopy): ------------------------------------------------------- Benchmark Time CPU Iterations ------------------------------------------------------- get_contents 781559706 ns 781443557 ns 1 After (per-part shallow copy): ------------------------------------------------------- Benchmark Time CPU Iterations ------------------------------------------------------- get_contents 33996069 ns 33987197 ns 20 PiperOrigin-RevId: 940664677
1 parent 199d954 commit 400f512

4 files changed

Lines changed: 261 additions & 7 deletions

File tree

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,9 @@ optional-dependencies.antigravity = [
9595
"google-antigravity>=0.1,<0.2",
9696
"protobuf>=6",
9797
]
98+
optional-dependencies.benchmark = [
99+
"google-benchmark>=1.9",
100+
]
98101
optional-dependencies.community = [
99102
"google-adk-community",
100103
]

src/google/adk/flows/llm_flows/contents.py

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414

1515
from __future__ import annotations
1616

17-
import copy
1817
import logging
1918
from typing import AsyncGenerator
2019
from typing import Optional
@@ -27,7 +26,7 @@
2726
from ...events.event import Event
2827
from ...models.llm_request import LlmRequest
2928
from ._base_llm_processor import BaseLlmRequestProcessor
30-
from .functions import remove_client_function_call_id
29+
from .functions import AF_FUNCTION_CALL_ID_PREFIX
3130
from .functions import REQUEST_CONFIRMATION_FUNCTION_CALL_NAME
3231
from .functions import REQUEST_EUC_FUNCTION_CALL_NAME
3332

@@ -512,6 +511,52 @@ def _is_timestamp_compacted(ts: float) -> bool:
512511
return [event for _, _, event in processed_items]
513512

514513

514+
def _copy_content_for_request(
515+
content: types.Content,
516+
*,
517+
strip_client_function_call_ids: bool,
518+
) -> types.Content:
519+
"""Returns a session-isolated copy of ``content`` for an LLM request.
520+
521+
``Content`` and every ``Part`` are shallow-copied so downstream request
522+
processors (nl_planning, code_execution) can mutate them without corrupting
523+
session events; payloads are shared by reference to avoid the deep recursion
524+
that the previous ``deepcopy`` paid on every request.
525+
526+
Because the copy is shallow, nested fields (e.g. ``function_call.args``,
527+
``inline_data.data``) are shared with the session events. Downstream
528+
processors must therefore only replace ``Part`` objects or set top-level
529+
``Part`` fields; mutating a nested field in place would corrupt session
530+
history.
531+
532+
Args:
533+
content: The (session-owned) content to copy. Not mutated.
534+
strip_client_function_call_ids: Whether to remove ``adk-`` prefixed function
535+
call/response ids (mirrors ``remove_client_function_call_id``).
536+
537+
Returns:
538+
An isolated ``Content`` safe to attach to an ``LlmRequest``.
539+
"""
540+
new_content = content.model_copy()
541+
parts = content.parts
542+
if not parts:
543+
return new_content
544+
545+
new_parts = []
546+
for part in parts:
547+
new_part = part.model_copy()
548+
if strip_client_function_call_ids:
549+
fc = new_part.function_call
550+
if fc and fc.id and fc.id.startswith(AF_FUNCTION_CALL_ID_PREFIX):
551+
new_part.function_call = fc.model_copy(update={'id': None})
552+
fr = new_part.function_response
553+
if fr and fr.id and fr.id.startswith(AF_FUNCTION_CALL_ID_PREFIX):
554+
new_part.function_response = fr.model_copy(update={'id': None})
555+
new_parts.append(new_part)
556+
new_content.parts = new_parts
557+
return new_content
558+
559+
515560
def _get_contents(
516561
current_branch: Optional[str],
517562
events: list[Event],
@@ -659,11 +704,13 @@ def _get_contents(
659704
# Convert events to contents
660705
contents = []
661706
for event in result_events:
662-
content = copy.deepcopy(event.content)
663-
if content:
664-
if not preserve_function_call_ids:
665-
remove_client_function_call_id(content)
666-
contents.append(content)
707+
if event.content:
708+
contents.append(
709+
_copy_content_for_request(
710+
event.content,
711+
strip_client_function_call_ids=not preserve_function_call_ids,
712+
)
713+
)
667714

668715
# for scoped agents (task / single_turn), prepend a
669716
# synthetic user-role content built from the originating FC's args.
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Microbenchmark for building LLM request contents from session history.
16+
17+
Times contents._get_contents over a large history with sizeable function-call
18+
payloads. To run the benchmark:
19+
20+
uv run python tests/benchmarks/benchmark_contents.py
21+
"""
22+
23+
from google.adk.events.event import Event
24+
from google.adk.flows.llm_flows import contents
25+
from google.genai import types
26+
import google_benchmark
27+
28+
_NUM_EVENTS = 500
29+
_PAYLOAD_SIZE = 200
30+
31+
32+
def _make_events(num_events: int, payload_size: int) -> list[Event]:
33+
"""Builds a session history with sizeable function-call payloads."""
34+
payload = {f"k{i}": list(range(payload_size)) for i in range(10)}
35+
events = [
36+
Event(
37+
invocation_id="inv0",
38+
author="user",
39+
content=types.UserContent("start"),
40+
)
41+
]
42+
for i in range(num_events):
43+
call_id = f"adk-call-{i}"
44+
events.append(
45+
Event(
46+
invocation_id=f"inv{i}",
47+
author="agent",
48+
content=types.Content(
49+
role="model",
50+
parts=[
51+
types.Part(
52+
function_call=types.FunctionCall(
53+
id=call_id, name="tool", args=dict(payload)
54+
)
55+
)
56+
],
57+
),
58+
)
59+
)
60+
events.append(
61+
Event(
62+
invocation_id=f"inv{i}",
63+
author="agent",
64+
content=types.Content(
65+
role="user",
66+
parts=[
67+
types.Part(
68+
function_response=types.FunctionResponse(
69+
id=call_id, name="tool", response=dict(payload)
70+
)
71+
)
72+
],
73+
),
74+
)
75+
)
76+
return events
77+
78+
79+
@google_benchmark.register
80+
def get_contents(state):
81+
events = _make_events(_NUM_EVENTS, _PAYLOAD_SIZE)
82+
while state:
83+
contents._get_contents(None, events, "agent")
84+
85+
86+
if __name__ == "__main__":
87+
google_benchmark.main()

tests/unittests/flows/llm_flows/test_contents.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from google.adk.agents.llm_agent import Agent
1616
from google.adk.events.event import Event
1717
from google.adk.events.event_actions import EventActions
18+
from google.adk.flows.llm_flows import _nl_planning
1819
from google.adk.flows.llm_flows import contents
1920
from google.adk.flows.llm_flows.contents import request_processor
2021
from google.adk.flows.llm_flows.functions import REQUEST_CONFIRMATION_FUNCTION_CALL_NAME
@@ -1001,6 +1002,122 @@ async def test_adk_function_call_ids_are_stripped_for_non_interactions_model():
10011002
assert user_fr_part.function_response.id is None
10021003

10031004

1005+
@pytest.mark.asyncio
1006+
async def test_stripping_function_call_ids_does_not_mutate_session_events():
1007+
"""Stripping ``adk-`` ids must not mutate the session-owned events."""
1008+
agent = Agent(model="gemini-2.5-flash", name="test_agent")
1009+
llm_request = LlmRequest(model="gemini-2.5-flash")
1010+
invocation_context = await testing_utils.create_invocation_context(
1011+
agent=agent
1012+
)
1013+
1014+
# Shared id so the history rearrange logic can pair call and response.
1015+
function_call_id = "adk-test-call-id"
1016+
fc_part = types.Part(
1017+
function_call=types.FunctionCall(
1018+
id=function_call_id,
1019+
name="test_tool",
1020+
args={"x": 1},
1021+
)
1022+
)
1023+
fr_part = types.Part(
1024+
function_response=types.FunctionResponse(
1025+
id=function_call_id,
1026+
name="test_tool",
1027+
response={"result": 2},
1028+
)
1029+
)
1030+
events = [
1031+
Event(
1032+
invocation_id="inv1",
1033+
author="user",
1034+
content=types.UserContent("Call the tool"),
1035+
),
1036+
Event(
1037+
invocation_id="inv2",
1038+
author="test_agent",
1039+
content=types.Content(role="model", parts=[fc_part]),
1040+
),
1041+
Event(
1042+
invocation_id="inv3",
1043+
author="test_agent",
1044+
content=types.Content(role="user", parts=[fr_part]),
1045+
),
1046+
]
1047+
invocation_context.session.events = events
1048+
1049+
async for _ in contents.request_processor.run_async(
1050+
invocation_context, llm_request
1051+
):
1052+
pass
1053+
1054+
model_fc_part = llm_request.contents[1].parts[0]
1055+
assert model_fc_part.function_call.id is None
1056+
user_fr_part = llm_request.contents[2].parts[0]
1057+
assert user_fr_part.function_response.id is None
1058+
1059+
assert fc_part.function_call.id == function_call_id
1060+
assert fr_part.function_response.id == function_call_id
1061+
assert events[1].content.parts[0].function_call.id == function_call_id
1062+
assert events[2].content.parts[0].function_response.id == function_call_id
1063+
assert model_fc_part is not fc_part
1064+
assert user_fr_part is not fr_part
1065+
1066+
1067+
@pytest.mark.asyncio
1068+
async def test_downstream_part_mutation_does_not_corrupt_session_events():
1069+
"""Request parts must survive in-place mutation by later processors."""
1070+
agent = Agent(model="gemini-2.5-flash", name="test_agent")
1071+
llm_request = LlmRequest(model="gemini-2.5-flash")
1072+
invocation_context = await testing_utils.create_invocation_context(
1073+
agent=agent
1074+
)
1075+
1076+
# A thought=True function-call part survives context filtering.
1077+
fc_part = types.Part(
1078+
function_call=types.FunctionCall(id="fc1", name="t", args={"x": 1}),
1079+
)
1080+
fc_part.thought = True
1081+
events = [
1082+
Event(
1083+
invocation_id="inv1",
1084+
author="user",
1085+
content=types.UserContent("Call the tool"),
1086+
),
1087+
Event(
1088+
invocation_id="inv2",
1089+
author="test_agent",
1090+
content=types.Content(role="model", parts=[fc_part]),
1091+
),
1092+
Event(
1093+
invocation_id="inv3",
1094+
author="test_agent",
1095+
content=types.Content(
1096+
role="user",
1097+
parts=[
1098+
types.Part(
1099+
function_response=types.FunctionResponse(
1100+
id="fc1", name="t", response={"r": 2}
1101+
)
1102+
)
1103+
],
1104+
),
1105+
),
1106+
]
1107+
invocation_context.session.events = events
1108+
1109+
async for _ in contents.request_processor.run_async(
1110+
invocation_context, llm_request
1111+
):
1112+
pass
1113+
1114+
# nl_planning clears thoughts in place on the request parts.
1115+
_nl_planning._remove_thought_from_request(llm_request)
1116+
1117+
assert fc_part.thought is True
1118+
assert events[1].content.parts[0].thought is True
1119+
1120+
10041121
@pytest.mark.asyncio
10051122
async def test_adk_function_call_ids_preserved_for_interactions_model():
10061123
"""Test ADK generated ids are preserved for interactions requests."""

0 commit comments

Comments
 (0)