Skip to content

Commit c8d4d4c

Browse files
authored
feat: Add streaming tool-call parse buffer limit to prevent excessive memory usage (#8811)
1 parent 4ecff61 commit c8d4d4c

8 files changed

Lines changed: 305 additions & 2 deletions

File tree

python/openai/README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -688,6 +688,17 @@ python3 openai_frontend/main.py \
688688
--tool-call-parser llama3
689689
```
690690

691+
During streaming, the tool-call parser re-parses the entire accumulated output with each new chunk. This means that very large tool-call arguments can significantly increase per-request CPU and memory usage. The `--max-tool-call-parse-bytes` flag sets a limit on the maximum number of bytes the streaming tool parser will process per request. If a streamed response exceeds this limit, the stream is truncated with finish_reason="length" and backend inference is cancelled.
692+
The default limit is 128 KiB (131,072 bytes). Increase this value if you expect larger tool-call payloads.
693+
Example usage:
694+
```
695+
python3 openai_frontend/main.py \
696+
--model-repository tests/vllm_models \
697+
--tokenizer meta-llama/Meta-Llama-3.1-8B-Instruct \
698+
--tool-call-parser llama3 \
699+
--max-tool-call-parse-bytes 131072
700+
```
701+
691702
Example for making a tool calling request:
692703

693704
```python

python/openai/openai_frontend/engine/triton_engine.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
import asyncio
3131
import base64
3232
import json
33+
import logging
3334
import time
3435
import uuid
3536
from dataclasses import dataclass
@@ -52,6 +53,7 @@
5253
from engine.utils.chat import load_chat_template, parse_chat_messages
5354
from engine.utils.tokenizer import get_tokenizer
5455
from engine.utils.tool_call_parsers import ToolCallParser, ToolParserManager
56+
from engine.utils.tool_call_parsers.utils import DEFAULT_MAX_TOOL_CALL_PARSE_BYTES
5557
from engine.utils.triton import (
5658
RequestKind,
5759
TritonLoraConfig,
@@ -96,6 +98,8 @@
9698
)
9799
from utils.utils import ClientError, ServerError
98100

101+
logger = logging.getLogger(__name__)
102+
99103

100104
# TODO: Improve type hints
101105
@dataclass
@@ -129,6 +133,7 @@ def __init__(
129133
lora_separator: Optional[str] = None,
130134
tool_call_parser: Optional[str] = None,
131135
chat_template: Optional[str] = None,
136+
max_tool_call_parse_bytes: int = DEFAULT_MAX_TOOL_CALL_PARSE_BYTES,
132137
):
133138
# Assume an already configured and started server
134139
self.server = server
@@ -137,6 +142,7 @@ def __init__(
137142
self.backend = backend
138143
self.lora_separator = lora_separator
139144
self.default_max_tokens = default_max_tokens
145+
self.max_tool_call_parse_bytes = max_tool_call_parse_bytes
140146

141147
self.model_metadata = self._get_model_metadata()
142148
self._metadata_lock = asyncio.Lock()
@@ -147,6 +153,12 @@ def __init__(
147153
)
148154
self.chat_template = load_chat_template(chat_template)
149155

156+
if self.tool_call_parser is not None:
157+
print(
158+
f"[INFO] Streaming tool-call parse buffer limit set to "
159+
f"{self.max_tool_call_parse_bytes} bytes"
160+
)
161+
150162
def ready(self) -> bool:
151163
return self.server.ready()
152164

@@ -658,6 +670,7 @@ async def _streaming_chat_iterator(
658670
)
659671

660672
previous_text = ""
673+
tool_parse_truncated = False
661674
include_usage = request.stream_options and request.stream_options.include_usage
662675
usage_accumulator = _StreamingUsageAccumulator(backend)
663676

@@ -671,6 +684,23 @@ async def _streaming_chat_iterator(
671684
if include_usage:
672685
usage_accumulator.update(response)
673686

687+
# The auto-tool parser re-parses the full accumulated text on
688+
# every chunk. Cap the buffer and cancel the backend to bound
689+
# per-request CPU and memory.
690+
if (
691+
tool_choice_auto
692+
and len(previous_text) + len(delta_text)
693+
> self.max_tool_call_parse_bytes
694+
):
695+
print(
696+
f"[WARNING] Streaming tool-call parse buffer exceeded "
697+
f"{self.max_tool_call_parse_bytes} bytes (request "
698+
f"{request_id}); truncating response and cancelling "
699+
f"backend inference."
700+
)
701+
tool_parse_truncated = True
702+
break
703+
674704
(
675705
response_delta,
676706
finish_reason,
@@ -717,6 +747,31 @@ async def _streaming_chat_iterator(
717747
)
718748
yield f"data: {chunk.model_dump_json(exclude_unset=True)}\n\n"
719749

750+
# On truncation, cancel the backend to stop it from populating the response
751+
# queue. Otherwise, abandoned streams may continue to consume memory.
752+
# The final chunk is sent with finish_reason="length" to explicitly
753+
# indicate to the client that a cutoff has occurred.
754+
if tool_parse_truncated:
755+
try:
756+
responses.cancel()
757+
except Exception:
758+
logger.debug(
759+
"Failed to cancel inference after tool-call parse "
760+
"truncation (request %s)",
761+
request_id,
762+
exc_info=True,
763+
)
764+
choice = ChatCompletionStreamingResponseChoice(
765+
index=0,
766+
delta=ChatCompletionStreamResponseDelta(content=""),
767+
logprobs=None,
768+
finish_reason=ChatCompletionFinishReason.length,
769+
)
770+
chunk = self._get_streaming_chat_response_chunk(
771+
choice, request_id, created, model, usage=None
772+
)
773+
yield f"data: {chunk.model_dump_json(exclude_unset=True)}\n\n"
774+
720775
# Send the final usage chunk if requested via stream_options.
721776
if include_usage:
722777
usage_payload = usage_accumulator.get_final_usage()

python/openai/openai_frontend/engine/utils/tool_call_parsers/utils.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
1+
# Copyright (c) 2025-2026, NVIDIA CORPORATION. All rights reserved.
22
#
33
# Redistribution and use in source and binary forms, with or without
44
# modification, are permitted provided that the following conditions
@@ -34,6 +34,13 @@
3434
import partial_json_parser
3535
from partial_json_parser.core.options import Allow
3636

37+
# Default value for --max-tool-call-parse-bytes CLI flag.
38+
# Specifies the maximum accumulated output (in bytes) that the
39+
# streaming tool-call parser processes per request.
40+
# Since the parser re-parses the entire buffer with each new chunk,
41+
# this limit helps bound per-request CPU and memory usage.
42+
DEFAULT_MAX_TOOL_CALL_PARSE_BYTES: int = 128 * 1024 # 128 KiB
43+
3744

3845
# partial_json_parser doesn't support extra data and
3946
# JSONDecorder.raw_decode doesn't support partial JSON

python/openai/openai_frontend/main.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333

3434
import tritonserver
3535
from engine.triton_engine import TritonLLMEngine
36+
from engine.utils.tool_call_parsers.utils import DEFAULT_MAX_TOOL_CALL_PARSE_BYTES
3637
from frontend.fastapi_frontend import FastApiFrontend
3738
from utils.utils import HTTP_DEFAULT_MAX_INPUT_SIZE, validate_positive_int
3839

@@ -134,6 +135,15 @@ def parse_args():
134135
default=None,
135136
help="Specify the parser for handling tool calling related response text. Options include: 'llama3' and 'mistral'.",
136137
)
138+
triton_group.add_argument(
139+
"--max-tool-call-parse-bytes",
140+
type=int,
141+
default=DEFAULT_MAX_TOOL_CALL_PARSE_BYTES,
142+
help="Maximum accumulated output (in bytes) that the streaming tool-call parser will process per request. "
143+
"Once this limit is reached, the stream is truncated with finish_reason='length' and backend inference is cancelled. "
144+
"This prevents unbounded memory growth caused by excessively large tool-call arguments. "
145+
f"Default: {DEFAULT_MAX_TOOL_CALL_PARSE_BYTES}.",
146+
)
137147
# Allows the user to try a different chat template to craft better prompts and receive more targeted tool-calling responses from the model.
138148
# Some Mistral models have a separate chat template file, in addition to the tokenizer_config.json,
139149
# such as mistralai/Mistral-Small-3.1-24B-Instruct-2503.
@@ -266,6 +276,7 @@ def main():
266276
backend=args.backend,
267277
lora_separator=args.lora_separator,
268278
tool_call_parser=args.tool_call_parser,
279+
max_tool_call_parse_bytes=args.max_tool_call_parse_bytes,
269280
chat_template=args.chat_template,
270281
default_max_tokens=args.default_max_tokens,
271282
)

python/openai/tests/test_tool_calling.py

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,18 +23,22 @@
2323
# OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
2424
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
2525
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
26+
27+
2628
import json
27-
import os
29+
from pathlib import Path
2830
from typing import Dict, List, Optional
2931

3032
import openai
3133
import pytest
34+
from fastapi.testclient import TestClient
3235
from openai.types.chat import (
3336
ChatCompletionMessageParam,
3437
ChatCompletionMessageToolCall,
3538
ChatCompletionNamedToolChoiceParam,
3639
ChatCompletionToolParam,
3740
)
41+
from tests.utils import setup_fastapi_app, setup_server
3842

3943
# resources for testing the tool callings
4044
WEATHER_TOOL: ChatCompletionToolParam = {
@@ -592,3 +596,82 @@ async def test_inconsistent_tool_choice_and_tools(
592596
tools=[],
593597
logprobs=False,
594598
)
599+
600+
601+
class TestStreamingToolParseLimit:
602+
"""
603+
Test the streaming tool-call parse-size limit. Utilizes the
604+
tool_parser_models/tool_stream_py model, which streams a Mistral-format
605+
tool call in small fragments. A prompt containing "big-tool-args" produces
606+
an argument large enough to exceed the configured limit, and any other prompt
607+
results in an argument that remains within the limit.
608+
"""
609+
610+
MODEL = "tool_stream_py"
611+
# Small enough that a single tool call can deterministically exceed it.
612+
LIMIT_BYTES = 512
613+
614+
@pytest.fixture(scope="class")
615+
def client(self, tokenizer_model: str):
616+
model_repository = str(Path(__file__).parent / "tool_parser_models")
617+
server = setup_server(model_repository)
618+
app = setup_fastapi_app(
619+
tokenizer=tokenizer_model,
620+
server=server,
621+
backend=None,
622+
tool_call_parser="mistral",
623+
max_tool_call_parse_bytes=self.LIMIT_BYTES,
624+
)
625+
with TestClient(app) as test_client:
626+
yield test_client
627+
server.stop()
628+
629+
def _stream(self, client, content, with_tools):
630+
"""Streams a chat completion
631+
returns (finish_reasons, streamed_text)."""
632+
payload = {
633+
"model": self.MODEL,
634+
"messages": [{"role": "user", "content": content}],
635+
"stream": True,
636+
"max_completion_tokens": 4096,
637+
}
638+
if with_tools:
639+
payload["tools"] = [WEATHER_TOOL]
640+
641+
finish_reasons: List[str] = []
642+
text = ""
643+
with client.stream("POST", "/v1/chat/completions", json=payload) as response:
644+
assert response.status_code == 200
645+
for line in response.iter_lines():
646+
if not line.startswith("data: "):
647+
continue
648+
data = line[len("data: ") :]
649+
if data.strip() == "[DONE]":
650+
break
651+
choice = json.loads(data)["choices"][0]
652+
if choice.get("finish_reason"):
653+
finish_reasons.append(choice["finish_reason"])
654+
delta = choice.get("delta") or {}
655+
if delta.get("content"):
656+
text += delta["content"]
657+
for tool_call in delta.get("tool_calls") or []:
658+
arguments = (tool_call.get("function") or {}).get("arguments")
659+
if arguments:
660+
text += arguments
661+
return finish_reasons, text
662+
663+
def test_truncated_when_tool_call_exceeds_limit(self, client):
664+
finish_reasons, _ = self._stream(client, "big-tool-args", with_tools=True)
665+
# Once the accumulated tool-call text passes the cap, the stream will be terminated with finish_reason="length".
666+
assert finish_reasons[-1] == "length"
667+
668+
def test_not_truncated_within_limit(self, client):
669+
finish_reasons, _ = self._stream(client, "small tool call", with_tools=True)
670+
# A short tool call stays under the cap and completes normally.
671+
assert "length" not in finish_reasons
672+
673+
def test_limit_ignored_without_tools(self, client):
674+
finish_reasons, text = self._stream(client, "big-tool-args", with_tools=False)
675+
# Plain streaming does not enter the tool parser, so the cap never fires.
676+
assert "length" not in finish_reasons
677+
assert len(text) > self.LIMIT_BYTES
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# Copyright 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
#
3+
# Redistribution and use in source and binary forms, with or without
4+
# modification, are permitted provided that the following conditions
5+
# are met:
6+
# * Redistributions of source code must retain the above copyright
7+
# notice, this list of conditions and the following disclaimer.
8+
# * Redistributions in binary form must reproduce the above copyright
9+
# notice, this list of conditions and the following disclaimer in the
10+
# documentation and/or other materials provided with the distribution.
11+
# * Neither the name of NVIDIA CORPORATION nor the names of its
12+
# contributors may be used to endorse or promote products derived
13+
# from this software without specific prior written permission.
14+
#
15+
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS ``AS IS'' AND ANY
16+
# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
17+
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
18+
# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
19+
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
20+
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
21+
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
22+
# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY
23+
# OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
24+
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
25+
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
26+
27+
28+
import numpy as np
29+
import triton_python_backend_utils as pb_utils
30+
31+
# Output is streamed in small fragments to mimic token-by-token generation,
32+
# which exercises the streaming tool-call parser.
33+
_CHUNK_SIZE = 8
34+
35+
36+
def _decode_prompt(request):
37+
tensor = pb_utils.get_input_tensor_by_name(request, "text_input").as_numpy()
38+
value = tensor.reshape(-1)[0]
39+
if isinstance(value, bytes):
40+
return value.decode("utf-8", errors="replace")
41+
return str(value)
42+
43+
44+
def _response(text):
45+
return pb_utils.InferenceResponse(
46+
[
47+
pb_utils.Tensor("text_output", np.array([[text]], dtype=object)),
48+
pb_utils.Tensor("num_input_tokens", np.array([[1]], dtype=np.int32)),
49+
pb_utils.Tensor("num_output_tokens", np.array([[1]], dtype=np.int32)),
50+
]
51+
)
52+
53+
54+
class TritonPythonModel:
55+
"""Decoupled mock that streams a Mistral-format tool call.
56+
57+
The argument length is driven by the prompt so tests can exercise both
58+
sides of the streaming tool-call parse-size limit:
59+
* a prompt containing "big-tool-args" -> a large argument that exceeds
60+
the limit;
61+
* any other prompt -> a small argument that stays within it.
62+
"""
63+
64+
def execute(self, requests):
65+
for request in requests:
66+
sender = request.get_response_sender()
67+
prompt = _decode_prompt(request)
68+
arg = "A" * (2000 if "big-tool-args" in prompt else 5)
69+
text = (
70+
'[TOOL_CALLS][{"name": "get_current_weather", '
71+
'"arguments": {"city": "' + arg + '"}}]'
72+
)
73+
for start in range(0, len(text), _CHUNK_SIZE):
74+
sender.send(_response(text[start : start + _CHUNK_SIZE]))
75+
sender.send(
76+
_response(""),
77+
flags=pb_utils.TRITONSERVER_RESPONSE_COMPLETE_FINAL,
78+
)
79+
return None

0 commit comments

Comments
 (0)