Skip to content

feat: per-request first-token deadline for LLM invocations - #814

Draft
lin-snow wants to merge 2 commits into
mainfrom
feat/llm-first-token-deadline
Draft

lin-snow wants to merge 2 commits into
mainfrom
feat/llm-first-token-deadline

Conversation

@lin-snow

@lin-snow lin-snow commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

Gives an LLM invocation a per-request first-token budget: the daemon forwards it to the plugin, which enforces it in the process holding the provider connection, and the daemon backs that up with a gate of its own for the case where the plugin cannot act.

Today a slow provider is bounded only by PLUGIN_MAX_EXECUTION_TIMEOUT (10 min, global). A latency-sensitive caller that needs a first token within seconds has no way to say so, and can only infer a stall from transport timing.

The plugin SDK side is langgenius/dify-plugin-sdks#384. Enforcement belongs there because it is the only form that works in every runtime: under serverless the plugin is reached by a single one-way HTTP POST, so the daemon has no channel to stop an in-flight invocation at all.

What changes

first_token_timeout on RequestInvokeLLM (seconds, omitempty). Forwarded to the plugin in the request payload — no new event type needed for the budget itself.

Two-phase gate in baseSSEService. Before the first output-carrying chunk the handler runs on first_token_timeout + ε; after it, back to PLUGIN_MAX_EXECUTION_TIMEOUT. The daemon sees frames, so this is exact — no dependence on header-flush timing or on there being zero bytes before the first token.

ε is max(250ms, 5% of budget). It is not a second opinion on the budget — a fallback has to be looser than the thing it backs up, or it fires first and the plugin never gets to release the provider connection. This gate is what catches an old plugin, or one whose runtime cannot interrupt a blocking read.

Envelope frames don't count. LLMResultChunk.CarriesFirstToken() looks for non-empty content or a tool call, so a provider that opens with an empty role delta and then stalls is still caught. The SDK applies the same rule to the same stream a hop earlier; the two have to agree, or one stream is judged differently at each layer. Other response types have no such method and count every chunk, so no other endpoint changes behaviour.

Streaming only. With stream: false there is one frame and it arrives when generation is done — enforcing there would silently turn a first-token budget into a total-time budget. Polling inherits the rule via RequestStartPolling.

Typed error frame on timeout: error_type: FirstTokenTimeoutError, with the budget and enforced_by: plugin_daemon in args.

cancel in-stream event. When the response stream closes without the plugin having settled it (end/error), the daemon now tells the plugin to stop. Previously a client disconnect only tore down the listener and deleted the session — the plugin ran its provider call to completion, so an abandoned request kept burning a provider connection and a retry doubled load on an already-slow provider.

plugin_time_to_first_token_seconds, so an operator picking a budget has something to pick it from.

Two fixes that fall out

Making abort-mid-stream a normal path rather than a rare one exposed two pre-existing bugs, both fixed here:

  • writeData was reachable concurrently from the reader goroutine and the timeout branch; -race flags the write to gin's response writer. Now serialized.
  • onCompletion could fire twice when the stream ended after a timeout or client disconnect, driving plugin_invocations_active negative. Now once.

Compatibility

Every combination degrades toward "looser but still bounded", never unbounded and never a false kill:

caller daemon plugin SDK behaviour
old new any no budget sent → global timeout, as today
new new new exact gate + real cancel + TTFT
new new old exact gate + session torn down; no cancel handling → provider call continues, as today

omitempty means a caller that sets nothing produces a byte-identical payload, so plugin SDKs that predate the field never see the key.

The cancel event is sent unconditionally because nothing on the wire carries an SDK version to gate on (PluginMeta has the manifest version, arch, and the Python interpreter version — not the SDK's). The failure mode on an old SDK is benign and verified: its reader wraps each line in try/except and answers an unknown event with one session-less error frame, which the daemon hands to notifiers that ignore it.

Serverless is excluded from cancel — a write there is an HTTP request, so it would start a second Lambda invocation instead of stopping the first. That runtime already cancels for real through the request context.

Notes for review

  • Ordering matters and is pinned by a test: the cancel hook is registered on the response stream ahead of the listener teardown, because the local runtime deletes the session→instance mapping in the listener's OnClose, after which a write returns ErrSessionNotFound.
  • The end frame deliberately does not carry a measured TTFT: the success stream is typed LLMResultChunk on both sides, so an extra frame would fail dify's validation. dify can measure its own TTFT — it knows when the first parsed chunk arrives — and the daemon exposes its own view as a metric.
  • No codegen changes. The budget is picked up by a type assertion in baseSSEWithSession, so *.gen.go regenerate identically.

Testing

go test ./internal/... ./pkg/... — clean apart from the five Redis-dependent packages that also fail on main without a local Redis (internal/cluster, internal/core/debugging_runtime, internal/core/session_manager, internal/service/debugging_service, pkg/utils/cache). New tests run green under -race, and each commit passes on its own.

lin-snow added a commit to langgenius/dify that referenced this pull request Sep 10, 2026
An LLM node had no way to say how long it will wait for the first token. The
only bound was PLUGIN_DAEMON_TIMEOUT: ten minutes, and global. A provider that
accepts the connection and then goes quiet held a latency-sensitive node for as
long as it liked.

The budget is configured per node as completion_params.first_token_timeout_ms
and popped into ModelInstance.first_token_timeout by
_normalize_completion_params - the only ms->s conversion, everything past that
point is seconds. From there it travels as a first-class request field:

    DifyPreparedLLM -> request_metadata["first_token_timeout"]
                    -> graphon, which already threads request_metadata opaquely
                    -> PluginModelRuntime
                    -> PluginModelClient.invoke_llm payload

It never enters model_parameters, so it cannot leak to a provider, and it is
omitted from the payload when unset, so a daemon that predates the field sees a
byte-identical request.

Enforcement happens where the provider connection actually lives. The plugin
process aborts the call at the budget and the daemon re-enforces at budget + e,
each answering with a typed error frame; both shapes now map to
FirstTokenTimeoutError, so error-strategy routing and tracing get a stable
error_type instead of having to infer a stall from a transport error.

The httpx read timeout is a backstop only and is now only ever widened, to
budget + 2e when the configured window is shorter. Narrowing it to the budget
would make it bound every inter-token gap too, which is not what the setting
means. If it does fire before any frame arrived then the daemon never answered
at all, and this side is the only layer left that can name the cause.

Streaming only. Without a stream the single result arrives once generation has
finished, so enforcing there would quietly turn a first-token budget into a
total-time budget; the parameter extractor node invokes with stream=False and
is unaffected.

The plugin-daemon test double mirrors the client signature, so it takes the new
parameter too.

The layers that enforce it are langgenius/dify-plugin-sdks#384 and
langgenius/dify-plugin-daemon#814.
…oken

RequestInvokeLLM gains an optional first_token_timeout, in seconds. It rides
the request payload through to the plugin and the daemon enforces it as a
second, tighter deadline: until the first chunk that actually carries output,
the SSE handler runs on first_token_timeout + e instead of
PLUGIN_MAX_EXECUTION_TIMEOUT, and once a token lands it switches back to the
global budget for the rest of the generation.

e is max(250ms, 5% of the budget). It is there so the daemon gate stays looser
than the budget a plugin enforces on itself, which leaves the error naming to
the layer that knows why the call stalled. When the daemon does fire first it
writes an error frame with error_type FirstTokenTimeoutError carrying the
budget and enforced_by plugin_daemon, so the caller never has to infer the
cause from transport timing.

Envelope frames do not satisfy the gate. LLMResultChunk.CarriesFirstToken
looks for non-empty content or a tool call, so a provider that opens with an
empty role delta and then stalls is still caught. Response types that cannot
tell the difference count every chunk, so no other endpoint changes behaviour.

The budget applies to streaming calls only. With stream false there is one
frame and it arrives when generation is done, so enforcing there would quietly
turn a first-token budget into a total-time budget; polling inherits the same
rule through RequestStartPolling.

The field is omitempty, so a caller that does not set one produces a payload
byte-identical to today's and plugin SDKs that predate the field never see the
key.

Two fixes fall out of making an abort mid-stream a normal path rather than a
rare one:

- writeData is now serialized. It was already reachable concurrently from the
  reader goroutine and the timeout branch, and -race flags the resulting write
  to gin's response writer on the pre-existing code path too.
- Completion is reported once. onCompletion could fire twice when the stream
  ended after a timeout or a client disconnect, which drove the
  plugin_invocations_active gauge negative.

Also records plugin_time_to_first_token_seconds, so an operator picking a
budget has something to pick it from.
When the caller goes away - a client disconnect, a request-context cancel, or
now a first-token timeout - the daemon closes the response stream, drops the
listener and deletes the session, but the plugin never hears about it. It runs
its provider call to completion, so an abandoned request keeps burning a
provider connection, and on a retry it multiplies load on a provider that is
already slow. The in-stream protocol had no way to say stop: only request and
backwards_response.

Add a third event, cancel, sent whenever the response stream closes without
the plugin having settled it with an end or an error frame. The hook is
registered on the stream ahead of the listener teardown so the cancel goes out
while the session is still routable: the local runtime deletes the
session-to-instance mapping in the listener's OnClose, after which a write
returns ErrSessionNotFound. A test pins that ordering.

Serverless is excluded. A write there is an HTTP request, so a cancel would
start a second Lambda invocation instead of stopping the first, and that
runtime already cancels for real through the request context.

Plugin SDKs that predate the event are unaffected. Their reader wraps each
line in a try/except and answers an unknown event with one session-less error
frame, which the daemon hands to notifiers that ignore it. Nothing on the wire
carries an SDK version to gate on and the failure mode is benign, so the event
goes out unconditionally.
@lin-snow
lin-snow force-pushed the feat/llm-first-token-deadline branch from c836984 to d68287a Compare September 11, 2026 09:00
lin-snow added a commit to langgenius/dify that referenced this pull request Sep 11, 2026
An LLM node had no way to say how long it will wait for the first token. The
only bound was PLUGIN_DAEMON_TIMEOUT: ten minutes, and global. A provider that
accepts the connection and then goes quiet held a latency-sensitive node for as
long as it liked.

The budget is configured per node as completion_params.first_token_timeout_ms
and popped into ModelInstance.first_token_timeout by
_normalize_completion_params - the only ms->s conversion, everything past that
point is seconds. From there it travels as a first-class request field:

    DifyPreparedLLM -> request_metadata["first_token_timeout"]
                    -> graphon, which already threads request_metadata opaquely
                    -> PluginModelRuntime
                    -> PluginModelClient.invoke_llm payload

It never enters model_parameters, so it cannot leak to a provider, and it is
omitted from the payload when unset, so a daemon that predates the field sees a
byte-identical request.

Enforcement happens where the provider connection actually lives. The plugin
process aborts the call at the budget and the daemon re-enforces at budget + e,
each answering with a typed error frame; both shapes now map to
FirstTokenTimeoutError, so error-strategy routing and tracing get a stable
error_type instead of having to infer a stall from a transport error.

The httpx read timeout is a backstop only and is now only ever widened, to
budget + 2e when the configured window is shorter. Narrowing it to the budget
would make it bound every inter-token gap too, which is not what the setting
means. If it does fire before any frame arrived then the daemon never answered
at all, and this side is the only layer left that can name the cause.

Streaming only. Without a stream the single result arrives once generation has
finished, so enforcing there would quietly turn a first-token budget into a
total-time budget; the parameter extractor node invokes with stream=False and
is unaffected.

The plugin-daemon test double mirrors the client signature, so it takes the new
parameter too.

The layers that enforce it are langgenius/dify-plugin-sdks#384 and
langgenius/dify-plugin-daemon#814.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant