Skip to content

Prompt cache is not saved when a request ends on a stop sequence or disconnect (regression from b620ae8 bump) #245

Open

Description

@solderzzc

Summary

After the mlx-swift-lm bump to b620ae8 (#243, merged as 57ea94b), the prompt KV cache is saved only on the producer's .info event. A request that ends early, either on a text-level stop sequence or on a client disconnect, never reaches .info, so its prompt is not saved. The next request that shares the prefix re-prefills the whole prompt.

Before the bump, the save ran on the first decode token, so an early stop still kept the prompt.

Where (at 57ea94b)

  • Sources/SwiftLM/Server.swift:3418: the streaming path saves in case .info, and the comment there says an early exit "never reaches .info and skips the save".
  • Sources/SwiftLM/Server.swift:3594: the non-streaming path has the same behavior at the stop-sequence break.

Impact

  • Any request that stops on a stop sequence or a disconnect leaves no prompt cache entry. The next turn of that conversation re-prefills the full history, so the cost grows with conversation length.
  • Not verified: whether the built-in stop strings (<|im_end|>, <end_of_turn>, and others) fire in practice before the EOS token id. If they do, most ordinary turns are affected, not only aborted ones.
  • Not measured: the time-to-first-token cost.

Suggested direction

Save the prompt on the first decode token again, or add a save on the stop and disconnect paths. The .info move exists to avoid a race with the decode step that is still writing the same KVCacheSimple, so any fix has to keep that race closed.

References

Activity

  1. HamedShams commented on Oct 28, 2026

    @HamedShams
    Contributor

    One more early-exit case for this list, found while working on #249, on main at ca8f3a2: a turn the model answers with a tool call and nothing else is never saved either.

    Why it happens: both handlers save at .info only once firstToken has been cleared (Server.swift:3419 streaming, :3595 non-streaming), and firstToken is cleared only in case .chunk (:3305, :3575). A reply that is only a tool call never produces a chunk, because the parser holds the whole <tool_call>…</tool_call> body and emits a single .toolCall. So when .info arrives the guard is still set and onPrefillDone is skipped, even though generation finished normally. This is older than the bump: before 2d8a787 the save sat inside the firstToken block, which such a turn never entered either.

    What I saw with the CI model (Qwen2.5-0.5B-Instruct-4bit) and a get_weather tool:

    • the tool-call turn (189 prompt tokens) logs no prefill done and saves nothing;
    • the next turn (241 prompt tokens) matches only the 26-token system prompt left over from an earlier conversation;
    • only after that turn, which ends in text, does the following one reuse the 241.

    In an agent loop most turns end in a tool call, so this is the common case rather than an edge one.

    Possible fix: by the time .info arrives, prefill has always completed, so the !firstToken guard looks unnecessary there and could be dropped. Alternatively firstToken could be cleared in case .toolCall as well. Either one covers this case on its own, independently of the stop-sequence and disconnect cases.

  2. added a commit that references this issue on Oct 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions