perf: stop re-copying the response text on every stream save (#28821)

Every streamed delta saves a snapshot of the in-progress response so a reconnecting client can resume it, and each save rebuilt the assistant text from scratch. On the Chat Completions path that re-joined every accumulated chunk, including on saves carrying no new text, so a long answer followed by a large tool call re-joined the whole answer once per argument chunk. The Responses API path never collects those chunks and reads the text back out of the output items instead, where the blank check copied it in full every time.

The joined string is now kept and reused until another chunk arrives, since content_parts is only ever appended to; the nonlocal declaration that suggested otherwise was already dead and is dropped, and inlining the single-use helper removes an unreachable branch with it. The blank check in get_output_text now tests the text rather than allocating a stripped copy of it, which is equivalent for all twelve of its callers. Text streaming on the Chat Completions path is unchanged, since a text delta always appends before it saves.

| stream | before | after |
| --- | --- | --- |
| 20k-char answer, 2000 tool-argument chunks | 21.4 ms | 0.06 ms |
| Responses API, 40k deltas, 200k chars | 80.7 ms | 50.5 ms |

Without Redis nothing extra is retained, since the snapshot store already held that string; with Redis one copy of the response text stays alive while the stream runs.
This commit is contained in:
Classic298
2026-08-25 21:41:54 +02:00
committed by GitHub
parent ac85b0f2a2
commit d198d950c6
2 changed files with 13 additions and 7 deletions
+11 -6
View File
@@ -4631,7 +4631,6 @@ async def streaming_chat_response_handler(response, ctx):
)
async def stream_body_handler(response, form_data):
nonlocal content_parts
nonlocal usage
nonlocal output
nonlocal prior_output
@@ -4648,22 +4647,28 @@ async def streaming_chat_response_handler(response, ctx):
last_delta_type = None
last_delta_key = None
def response_stream_content(stream_output: list | None = None):
return ''.join(content_parts) or get_output_text(
stream_output if stream_output is not None else full_output()
)
joined_content = ''
joined_part_count = 0
async def save_current_response_stream(stream_output: list | None = None):
nonlocal joined_content
nonlocal joined_part_count
if not chat_id or not metadata.get('message_id'):
return
# content_parts is append-only, so its length tells us when the join is stale
if joined_part_count != len(content_parts):
joined_content = ''.join(content_parts)
joined_part_count = len(content_parts)
current_stream_output = stream_output if stream_output is not None else full_output()
await save_response_stream(
request.app.state.redis,
response_stream_task_id,
chat_id,
metadata.get('message_id'),
response_stream_content(current_stream_output),
joined_content or get_output_text(current_stream_output),
current_stream_output,
)
+2 -1
View File
@@ -254,7 +254,8 @@ def get_output_text(output: list | None) -> str:
text = ''.join(
str(part.get('text')) for part in parts if isinstance(part, dict) and part.get('text') is not None
)
if text.strip():
# isspace() avoids the full-string copy strip() would make
if text and not text.isspace():
texts.append(text)
return '\n'.join(texts)