fix: recover per-token ids and logprobs from content[] to stop RL KLD corruption#458
Merged
Merged
Conversation
…lient The /v1/completions sampling path corrupted RL training via retokenization drift. The request never set return_token_ids, so choices[].token_ids came back empty and the client silently re-encoded decoded text. Retokenization drops the trailing end-of-turn/EOS token, making completion_ids one shorter than token_logprobs and misaligning every per-token logprob (inference KLD spiked to ~60 vs ~0.028 with exact ids). - Default return_token_ids=True in the request payload (overridable via request_params). - Remove the tokenizer.encode() re-encode fallback; raise when exact ids are absent instead of silently returning corrupted data. - Assert len(completion_token_ids) == len(completion_logprobs) at the boundary to catch any residual drift. Co-authored-by: Cursor <cursoragent@cursor.com>
…shape This client always requests boolean logprobs=True, so every response uses the new content[] logprobs shape. Read the exact per-token ids and logprobs together from content[] (token_id + sampling_logprob) so they are aligned by construction. Bug 1 (alignment / KLD corruption) — the real fix: previously ids were read only from the top-level choices[].token_ids (populated by return_token_ids, which this client never requested) and, when absent, silently re-derived via tokenizer.encode(decode(text)). Retokenization drops the trailing end-of-turn/EOS token, making completion_ids one shorter than the logprobs and misaligning every per-token logprob (inference_kld ~60 vs ~0.028). Since content[] already carries token_id per entry, reading ids there removes the drift entirely — no return_token_ids, no re-encode fallback. Bug 2 (precision) — more of a feature: prefer content[].sampling_logprob (the exact value the sampler drew with) over content[].logprob (rounded). - Drop legacy token_logprobs / top-level token_ids / raw_output id sources. - Fail loud when content[] is absent or a content[] entry lacks token_id, instead of silently returning corrupted data. Co-authored-by: Cursor <cursoragent@cursor.com>
Always read the per-token logprob from content[].sampling_logprob (the exact value the sampler drew with). Fail loud if any content[] entry lacks sampling_logprob instead of substituting the rounded content[].logprob or 0.0, so silently degraded logprobs never reach RL training. Removes the now-unused _extract_entry_logprob helper. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
FireworksV1CompletionsClient.create_completion_from_prompt_idsis the sampling path for RL rollouts against Fireworks/v1/completions. Downstream, the trainer aligns each per-token inference logprob to its completion token id position-by-position, socompletion_ids[i]and the per-token logprob atimust be the same length and the same tokens.This client always requests boolean
logprobs=True, so every response uses the newcontent[]logprobs shape (choices[0].logprobs.content[], where each entry carriestoken_id,token,bytes,logprob, andsampling_logprob). The legacy integerlogprobs=1shape (token_logprobs+logprobs.token_ids) is not requested and is intentionally not supported here. This PR makes the client read everything from that new-modecontent[]array.Bug 1 — alignment / KLD corruption (the actual bug)
Per-token ids and logprobs were read from two independent places:
choices[].token_ids, which is populated only whenreturn_token_ids=True— a flag this client never sent. When absent, ids were silently re-derived viatokenizer.encode(decode(text), add_special_tokens=False).content[].logprob, always full length.Retokenization does not reproduce the model's exact sequence — the trailing end-of-turn/EOS token decodes to empty text and is not re-added — so
completion_ids = Nwhile logprobs =N+1. The +1 shift misaligns every per-token logprob against the wrong token, and per-token KLD becomes garbage.Observed on a GLM-family LoRA RFT deployment (MTP speculative decoding):
completion_idswas one shorter than the logprobs on all 120 rows (diff=+1) andinference_kldclimbed to ~60; aligning ids correctly made diff=0 andinference_kldflat ~0.028.Fix: the
content[]array already carriestoken_idper entry, so we read ids and logprobs from the same array, entry-by-entry. They are the same length and aligned by construction — noreturn_token_ids, no re-encode fallback.Bug 2 — sampling precision (more of a feature)
In the
content[]shape each entry exposes two numbers:sampling_logprob(the exact, full-precision value the sampler actually drew with) andlogprob(a rounded/verification value). The client read the roundedlogprob. We now readsampling_logprob(the correct inference-side value for KLD) and fail loud if it is missing.Changes
completion_idsfromcontent[].token_idand logprobs fromcontent[].sampling_logprobin a single pass, so they are aligned by construction.tokenizer.encode(...)re-encode fallback and the legacy id/logprob sources (token_logprobs, top-leveltoken_ids,raw_output.completion_token_ids).return_token_ids; rely on thecontent[]shape that booleanlogprobs=Truealready returns.content[]is absent (e.g. logprobs disabled or legacy shape returned) or when acontent[]entry lackstoken_idorsampling_logprob, instead of silently returning corrupted data.Mirrors the intent of the upstream Fireworks monorepo fix (fw-ai/fireworks#33729): callers that use the base
eval_protocolclient unmodified (e.g.examples/multihop_qa/multihop_qa_rollout.py) are no longer silently corrupted.Test plan
content[]withtoken_id+sampling_logprob->completion_idsfromtoken_id, logprobs fromsampling_logprob, lengths equal, tokenizer never used to re-encode.content[]-> raises (no silent re-encode).token_logprobsshape (nocontent[]) -> raises (legacy not supported).content[]entry missingtoken_id-> raises.content[]entry missingsampling_logprob-> raises.pytest tests/test_fireworks_v1_completions_client.pypasses (13 tests).Supersedes #457 (same commits, moved from a fork branch to an in-repo branch so CI runs with secrets). Previously approved by @websterbei.
Made with Cursor