perf(agent): reuse the per-request OpenAI wire client across sequential LLM calls - #64170
perf(agent): reuse the per-request OpenAI wire client across sequential LLM calls#64170Soju06 wants to merge 1 commit into
Conversation
a855b43 to
bfe8f97
Compare
906629e to
f5d4676
Compare
|
Thanks for the focused lifecycle work. Current The cache remains limited to the existing request-client seam, retains the per-request Automated hermes-sweeper review. |
f5d4676 to
78f5d4b
Compare
5cf1579 to
14cf847
Compare
98a0455 to
e4ce1a9
Compare
…al LLM calls Every LLM call built a fresh openai.OpenAI wire client (new httpx pool, TCP+TLS handshake, measured 19.2ms p50 / 35.5ms p95 per call at ~5 calls per tool-loop turn) and closed it when the request finished. Cache ONE reusable wire client on the agent, keyed by the effective client kwargs: - _create_request_openai_client hands back the cached client when the effective kwargs are identical; any change (credential rotation, provider failover, vision default_headers) evicts and rebuilds. - Only a request that produced a response reports a reuse close reason (request_complete / stream_request_complete); error unwinds report *_error_cleanup and really close, so a retry after a request error always builds a fresh pool. - Cross-thread aborts poison the slot: a pool whose sockets were shutdown(SHUT_RDWR) from a stranger thread is never reused (NousResearch#29507) — the owner-thread close discards it and the next create rebuilds. The holder read and the abort are atomic (under the holder lock) at all three abort sites, so a late abort can never poison the NEXT request's checked-out client. - Worker-side interrupt breaks close the half-read SSE stream on the owning thread before building the partial response; a failed close poisons the slot (otherwise each interrupt leaked one checked-out connection until the pool hit PoolTimeout). run_codex_stream gets the same poison-on-close-failure handling. - Single checked-out slot (in_use): a concurrent call gets an untracked client with the old per-request lifecycle. - release_clients() / close() really close the cached client when idle; if a worker has it checked out they abort the sockets and detach the slot, deferring the FD release to the worker's own close. - MoA facade and Mock passthroughs never enter the cache; max_retries=0 is preserved on all request clients.
e4ce1a9 to
f112594
Compare
|
Merged via #73375 (rebase — your commit landed on main as 82e2c9c with your authorship preserved). Review notes: the live before/after E2E reproduced your measurements exactly — 4 sequential calls went from 4 wire-client builds (28-42ms each) to 1 build + 3 reuse hits, and every negative path (kwargs eviction, error-path rebuild, streaming reuse, teardown close, cross-thread poison) checked out both in your test suite and in live verification. Exceptionally well-reasoned PR — the correctness notes matched the code exactly. Thank you! |
Problem
Every LLM call builds a fresh
openai.OpenAIwire client in_create_request_openai_clientand closes it when the request finishes. Constructing the client allocates a new httpx connection pool, so every call pays a fresh TCP + TLS handshake to the provider.Measured in a production deployment (instrumented client construction):
The per-request client exists for good reasons (SDK
max_retries=0, per-request vision headers, the #29507 cross-thread close discipline), so the fix keeps the per-request lifecycle semantics and only skips the redundant rebuild when nothing about the request's client configuration changed.Change
Cache ONE reusable wire client on the agent, keyed by the effective client kwargs:
_create_request_openai_clienthands back the cached client when the effective kwargs are identical to the cached ones; any kwargs change (credential rotation, provider failover, vision headers) evicts the stale client and rebuilds._close_request_openai_clientkeeps the client for the next call only when the close reason is one of_REQUEST_CLIENT_REUSE_REASONS(request_complete/stream_request_complete) — the reasons the request workers' ownfinallyreports for a request that actually produced a response. Every other reason (error cleanups, stale/interrupt kills, retry cleanups) does a real close.in_use): a second concurrent call gets a fresh untracked client with the old build-per-request lifecycle, so two calls never share one pool's close/abort lifecycle.release_clients()/close()really close the cached client via a new_close_cached_request_openai_clientteardown hook.The callers in
agent/chat_completion_helpers.pynow report a reuse-eligible close reason only when the request produced a response; error unwinds reportrequest_error_cleanup/stream_error_cleanupso a retry after a request error always builds a fresh pool.Correctness notes
max_retries=0preserved. The cached client is built through the samerequest_kwargspath, and a kwargs mismatch (includingmax_retries) evicts. The agent's outer retry loop remains the only retry loop.client.close()must only run on the thread that owns the pool's FDs. A stranger-thread abort (_abort_request_openai_client, used by the interrupt-check loop and the stale-call detector) onlyshutdown(SHUT_RDWR)s the sockets — and now also poisons the cache slot, so a pool whose sockets were shut down from another thread is never reused: the owner thread's close discards it and the next create rebuilds. Poisoning wins over a reuse-reason close.finallycould pop + cache the client and the next call check it out, so the late abort would poison the slot and shut down an innocent in-flight request's sockets. The abort itself never blocks (socket shutdown + slot poison), so holding the lock across it only delays the racing pop, never the data path. The same discipline is applied to cron's inlinedirect_api_callabort.finallyreport a reuse-reason close — caching the client together with the leaked connection (one more per interrupt until the pool exhausted and every request hitPoolTimeout). The stream is now closed on the break (owner thread); if that close fails, the slot is poisoned so thefinallyreally closes the pool. The codex streaming runtime gets the same poison-on-close-failure treatment.default_headersvariant produces different effective kwargs, so a vision call after a text call (or vice versa) rebuilds rather than reusing the wrong headers. Nested kwargs dicts are snapshotted at cache time so an aliased inner dict mutated in place can't spuriously compare equal._client_kwargs; the kwargs comparison fails and the stale client is evicted (closed from the creating thread, which is safe becausein_usewas false — no worker owns the pool's FDs).test_retry_after_api_connection_error_recreates_request_client).finallysees an untracked client and does the real close on its thread.Tests
New:
tests/agent/test_request_client_reuse.py— reuse on identical kwargs, eviction on kwargs change, vision-header variant keying, poison-on-abort, non-reuse close reasons, teardown (idle and checked-out), single-slot concurrency, MoA/Mock passthrough.tests/run_agent/test_request_client_reuse_abort_races.py— atomic holder-abort races (streaming, non-streaming, and cron inline paths), interrupt-break stream close and poison-on-close-failure, codex stream close-failure poisoning.Updated:
tests/run_agent/test_openai_client_lifecycle.py— successful requests now keep the wire client cached (closed at teardown instead of request end); the retry-after-connection-error test still pins that error paths rebuild.Ran
tests/run_agent,tests/agent,tests/cron/test_cron_direct_api_call_62151.py,tests/hermes_cli/test_codex_runtime_switch.py, andtests/acp/test_session.pylocally (Linux).Measured impact
From a production deployment of this change: client construction cost dropped from 19.2ms p50 / 35.5ms p95 per LLM call to 0.008–0.039ms on reuse. At ~5.1 calls per turn this saves ~75–80ms of pure connection-setup latency per typical tool-loop turn, plus the provider-side benefit of a persistent connection across sequential calls.
🤖 Generated with Claude Code
🤖 Generated with Claude Code