Skip to content

Commit 454f979

Browse files
authored
Add end-to-end sampling controls to the LLM server worker protocol (#21561)
## Summary Add end-to-end sampling controls to the LLM server worker protocol. - Plumb `top_p`, `top_k`, and `seed` from chat completion requests through the Python runtime and JSONL worker protocol. - Extend the C++ worker sampling configuration while preserving existing defaults and greedy behavior. - Validate sampling parameters at the API boundary and document their supported ranges and semantics. - Add coverage for request validation, worker serialization, session propagation, and C++ protocol handling. ## Test Plan - Ran the focused Python LLM server test suite: 78 tests passed. - Built the C++ worker integration successfully. - Verified deterministic output for repeated seeded requests, different output for different or omitted seeds, and expected behavior for `top_p` and `top_k`. - Verified invalid sampling values return HTTP 400 responses.
1 parent 0008582 commit 454f979

12 files changed

Lines changed: 204 additions & 36 deletions

examples/llm_server/README.md

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,17 @@ See `python/README.md` to run it.
2626
Status: experimental, reliability-first and deliberately narrow. Implemented:
2727
`/health`, `/v1/models`, `/v1/chat/completions` (streaming + non-streaming),
2828
Hugging Face chat templates (`--hf-tokenizer`), `temperature` / `max_tokens` /
29-
`max_completion_tokens` / `stop`, Hermes tool calling by default
29+
`max_completion_tokens` / `stop`, sampling controls `top_p` / `top_k` / `seed`
30+
(these only affect output when `temperature > 0`; under the default greedy
31+
decoding `temperature = 0` they are accepted but have no effect, and an omitted
32+
`seed` uses the worker's unset/random value), Hermes tool calling by default
3033
(`<tool_call>...</tool_call>` JSON, complete calls only; model-specific launchers
3134
may select the Qwen XML format) with `tool_choice="none"`,
3235
structured API errors, and best-effort cancellation. One worker process with
3336
serialized execution; a worker can host isolated sessions on one weight load when its engine reports
3437
capacity > 1 (with warm append-only resume across turns). KV/prefix state lives inside the
35-
worker/session, not the control plane. Unsupported params (including `top_p`,
36-
`seed`, `n>1`, `reasoning_effort`, penalties, `logit_bias`, `response_format`,
38+
worker/session, not the control plane. Unsupported params (including
39+
`n>1`, `reasoning_effort`, penalties, `logit_bias`, `response_format`,
3740
`logprobs`, and `tool_choice="required"`) are rejected with a structured 400
3841
rather than silently ignored. See `python/README.md` to run it and
3942
`spec/README.md` for the exact contract.
@@ -85,8 +88,10 @@ Supported contract for pi:
8588
- `tool_choice`: only `"auto"`, `"none"`, or unset.
8689
- Rejected with a structured 400 (`unsupported_parameter`), not silently
8790
ignored: `tool_choice="required"` or specific-function forcing,
88-
`response_format` JSON/constrained output, `logprobs`, `top_p` other than
89-
`1.0`, and `seed`.
91+
`response_format` JSON/constrained output, and `logprobs`.
92+
- `top_p`, `top_k`, and `seed` are supported, but only take effect when
93+
`temperature > 0`; the default greedy decoding (`temperature = 0`) ignores
94+
them, and an omitted `seed` uses the worker's unset/random value.
9095

9196
Reliability guidance:
9297

examples/llm_server/cpp/test_worker_loop.cpp

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
#include <cstdio>
1919
#include <iostream>
2020
#include <iterator>
21+
#include <limits>
2122
#include <sstream>
2223
#include <string>
2324
#include <unordered_map>
@@ -64,13 +65,18 @@ class FakeSession : public LLMSession {
6465
int fail_prefill_on = -1; // 0-based call index to fail (-1 = never)
6566
int decode_calls = 0;
6667
int fail_decode_on = -1;
68+
std::vector<SamplingConfig> prefill_sampling;
69+
std::vector<SamplingConfig> decode_sampling;
6770
int reset_calls = 0;
6871
bool fail_reset = false;
6972

7073
ETError prefill_tokens(
7174
const std::vector<uint64_t>& tokens,
72-
const SamplingConfig* /*initial_sampling*/ = nullptr) override {
75+
const SamplingConfig* initial_sampling = nullptr) override {
7376
prefill_sizes.push_back(tokens.size());
77+
if (initial_sampling != nullptr) {
78+
prefill_sampling.push_back(*initial_sampling);
79+
}
7480
prefill_batches.push_back(tokens);
7581
if (prefill_calls++ == fail_prefill_on) {
7682
return ETError::Internal; // failed AFTER (notionally) mutating state
@@ -79,7 +85,8 @@ class FakeSession : public LLMSession {
7985
return ETError::Ok;
8086
}
8187

82-
ETResult<DecodeResult> decode_one(const SamplingConfig& /*s*/) override {
88+
ETResult<DecodeResult> decode_one(const SamplingConfig& sampling) override {
89+
decode_sampling.push_back(sampling);
8390
if (decode_calls++ == fail_decode_on) {
8491
return ETError::Internal;
8592
}
@@ -219,6 +226,67 @@ nlohmann::json idsReq(std::vector<uint64_t> ids, int64_t max_new = 8) {
219226
return {{"max_new_tokens", max_new}, {"prompt_segments", {{{"ids", ids}}}}};
220227
}
221228

229+
bool sameSampling(
230+
const SamplingConfig& sampling,
231+
float temperature,
232+
float top_p,
233+
int32_t top_k,
234+
uint64_t seed) {
235+
return sampling.temperature == temperature && sampling.top_p == top_p &&
236+
sampling.top_k == top_k && sampling.seed == seed;
237+
}
238+
239+
void test_sampling_config_forwarded() {
240+
auto st = makeState();
241+
fake(st).steps = {{10, "a", false, false}, {0, "", true, true}};
242+
auto req = idsReq({1, 2, 3});
243+
req["temperature"] = 0.7;
244+
req["top_p"] = 0.8;
245+
req["top_k"] = 32;
246+
req["seed"] = 123;
247+
run(st, /*warm=*/false, req);
248+
249+
check(
250+
"sampling: explicit config reaches prefill",
251+
fake(st).prefill_sampling.size() == 1 &&
252+
sameSampling(fake(st).prefill_sampling[0], 0.7f, 0.8f, 32, 123));
253+
check(
254+
"sampling: explicit config reaches every decode",
255+
fake(st).decode_sampling.size() == 2 &&
256+
std::all_of(
257+
fake(st).decode_sampling.begin(),
258+
fake(st).decode_sampling.end(),
259+
[](const SamplingConfig& sampling) {
260+
return sameSampling(sampling, 0.7f, 0.8f, 32, 123);
261+
}));
262+
263+
auto defaults = makeState();
264+
fake(defaults).steps = {{0, "", true, true}};
265+
run(defaults, /*warm=*/false, idsReq({1}));
266+
check(
267+
"sampling: omitted fields use worker defaults",
268+
fake(defaults).prefill_sampling.size() == 1 &&
269+
sameSampling(fake(defaults).prefill_sampling[0], 0.0f, 1.0f, 0, 0));
270+
}
271+
272+
void test_invalid_sampling_config_rejected() {
273+
for (auto invalid : std::vector<nlohmann::json>{
274+
{{"top_p", 0.0}},
275+
{{"top_k", -1}},
276+
{{"top_k",
277+
static_cast<int64_t>(std::numeric_limits<int32_t>::max()) + 1}},
278+
{{"seed", -1}},
279+
}) {
280+
auto st = makeState();
281+
auto req = idsReq({1});
282+
req.update(invalid);
283+
const auto em = run(st, /*warm=*/false, req);
284+
check(
285+
"sampling: invalid direct worker value rejected before prefill",
286+
em.threw && fake(st).prefill_calls == 0);
287+
}
288+
}
289+
222290
void test_new_full_prefill() {
223291
auto st = makeState();
224292
fake(st).steps = {{10, "a", false, false}, {0, "", true, true}};
@@ -446,6 +514,8 @@ void test_reset_named_only_clears_on_success() {
446514

447515
int main() {
448516
printf("worker_loop.h harness:\n");
517+
test_sampling_config_forwarded();
518+
test_invalid_sampling_config_rejected();
449519
test_new_full_prefill();
450520
test_exact_prefix_warm_suffix();
451521
test_mismatch_full_reset();

examples/llm_server/cpp/worker_loop.h

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,11 @@
6262

6363
#include <algorithm>
6464
#include <chrono>
65+
#include <cmath>
6566
#include <cstdint>
6667
#include <iostream>
6768
#include <iterator>
69+
#include <limits>
6870
#include <memory>
6971
#include <stdexcept>
7072
#include <string>
@@ -115,6 +117,23 @@ inline void worker_handle_request(
115117
LLMSession& session = *st.session;
116118
int64_t max_new = req.value("max_new_tokens", static_cast<int64_t>(-1));
117119
const float temperature = req.value("temperature", 0.0f);
120+
const double top_p_value = req.value("top_p", 1.0);
121+
const int64_t top_k_value = req.value("top_k", static_cast<int64_t>(0));
122+
const int64_t seed_value = req.value("seed", static_cast<int64_t>(0));
123+
if (!std::isfinite(top_p_value) || top_p_value <= 0.0 || top_p_value > 1.0) {
124+
throw std::runtime_error("top_p must be finite and in (0, 1]");
125+
}
126+
if (top_k_value < 0 || top_k_value > std::numeric_limits<int32_t>::max()) {
127+
throw std::runtime_error("top_k must fit in a nonnegative int32");
128+
}
129+
// seed == 0 means "unset" (SamplingConfig::seed == 0 -> worker picks a random
130+
// seed), so 0 is intentionally accepted here even though the HTTP layer
131+
// treats 0 as the omitted sentinel and rejects an explicit seed=0. A JSON
132+
// seed >= 2^63 won't fit int64_t and is rejected at parse time above; the
133+
// HTTP layer caps the same range at 2^63 - 1 with a structured error.
134+
if (seed_value < 0) {
135+
throw std::runtime_error("seed must be nonnegative");
136+
}
118137
// Stop strings (the request's `stop` sequences): terminate at the token
119138
// boundary where one appears so we don't generate to EOS/max_new past it. The
120139
// control plane also enforces these as a backstop.
@@ -207,6 +226,9 @@ inline void worker_handle_request(
207226

208227
SamplingConfig sampling;
209228
sampling.temperature = temperature;
229+
sampling.top_p = static_cast<float>(top_p_value);
230+
sampling.top_k = static_cast<int32_t>(top_k_value);
231+
sampling.seed = static_cast<uint64_t>(seed_value);
210232
const auto prefill_start = std::chrono::steady_clock::now();
211233
if (session.prefill_tokens(to_prefill, &sampling) !=
212234
::executorch::runtime::Error::Ok) {

examples/llm_server/python/protocol.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,6 @@ class ChatCompletionRequest(BaseModel):
6363
stop: Optional[Union[str, list[str]]] = None
6464
n: int = 1
6565
seed: Optional[int] = None
66-
# Sampling knobs that change generation output. We don't plumb these, so they
67-
# are modeled (not dropped) in order to be rejected with a clear error rather
68-
# than silently ignored — see serving_chat's unsupported-parameter check.
6966
frequency_penalty: Optional[float] = None
7067
presence_penalty: Optional[float] = None
7168
top_k: Optional[int] = None

examples/llm_server/python/serving_chat.py

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,9 @@ def _options(
266266
return GenerationOptions(
267267
max_new_tokens=req.resolved_max_tokens(),
268268
temperature=req.temperature if req.temperature is not None else 0.0,
269+
top_p=req.top_p if req.top_p is not None else 1.0,
270+
top_k=req.top_k if req.top_k is not None else 0,
271+
seed=req.seed if req.seed is not None else 0,
269272
# Worker stop set, chosen per path in create() (see __init__ for the
270273
# two sets); the server re-applies it in _clean/_collect_until_stop.
271274
stop=stops,
@@ -347,6 +350,29 @@ def _reject_invalid_values(req: ChatCompletionRequest) -> None:
347350
"invalid_request_error",
348351
"invalid_value",
349352
)
353+
if req.top_p is not None and (
354+
not math.isfinite(req.top_p) or req.top_p <= 0.0 or req.top_p > 1.0
355+
):
356+
raise APIError(
357+
400,
358+
f"top_p must be between 0 (exclusive) and 1 (got {req.top_p}).",
359+
"invalid_request_error",
360+
"invalid_value",
361+
)
362+
if req.top_k is not None and not (0 <= req.top_k <= 2**31 - 1):
363+
raise APIError(
364+
400,
365+
f"top_k must be between 0 and {2**31 - 1} (got {req.top_k}).",
366+
"invalid_request_error",
367+
"invalid_value",
368+
)
369+
if req.seed is not None and not (0 < req.seed <= 2**63 - 1):
370+
raise APIError(
371+
400,
372+
f"seed must be between 1 and {2**63 - 1} (got {req.seed}).",
373+
"invalid_request_error",
374+
"invalid_value",
375+
)
350376
# max_tokens / max_completion_tokens, if given, must be positive integers
351377
# (OpenAI rejects 0 and negatives; our -1 sentinel means "unset/auto").
352378
for field_name in ("max_tokens", "max_completion_tokens"):
@@ -362,20 +388,17 @@ def _reject_invalid_values(req: ChatCompletionRequest) -> None:
362388
@staticmethod
363389
def _reject_unsupported_params(req: ChatCompletionRequest) -> None:
364390
"""Reject params we don't honor rather than silently ignoring them (a
365-
client relying on e.g. top_p/seed/logprobs would otherwise get wrong
366-
behavior). Only the no-op/default value of each passes: top_p exactly
367-
1.0; penalties 0; response_format type "text"; tool_choice none/auto/
368-
unset; parallel_tool_calls true (false can't be guaranteed without
391+
client relying on e.g. penalties/logprobs would otherwise get wrong
392+
behavior). Only the no-op/default value of each passes: penalties 0;
393+
response_format type "text"; tool_choice none/auto/unset;
394+
parallel_tool_calls true (false can't be guaranteed without
369395
constraining); logprobs are not returned at all."""
370396
rf = req.response_format
371397
flags = [
372398
(req.n != 1, "n>1"),
373-
(req.top_p is not None and req.top_p != 1.0, "top_p"),
374-
(req.seed is not None, "seed"),
375399
(req.reasoning_effort is not None, "reasoning_effort"),
376400
(bool(req.frequency_penalty), "frequency_penalty"),
377401
(bool(req.presence_penalty), "presence_penalty"),
378-
(req.top_k is not None, "top_k"),
379402
(bool(req.logit_bias), "logit_bias"),
380403
(
381404
bool(rf) and rf.get("type", "text") != "text",
@@ -394,8 +417,8 @@ def _reject_unsupported_params(req: ChatCompletionRequest) -> None:
394417
raise APIError(
395418
400,
396419
f"Unsupported parameter(s): {', '.join(unsupported)}. This server honors "
397-
"temperature, max_tokens/max_completion_tokens, stop, and tools for the "
398-
"configured tool-call format.",
420+
"temperature, top_p, top_k, seed, max_tokens/max_completion_tokens, "
421+
"stop, and tools for the configured tool-call format.",
399422
"invalid_request_error",
400423
"unsupported_parameter",
401424
)

examples/llm_server/python/session_runtime.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ class GenerationOptions:
5555

5656
max_new_tokens: int
5757
temperature: float = 0.0
58+
top_p: float = 1.0
59+
top_k: int = 0
60+
seed: int = 0
5861
stop: list[str] = field(default_factory=list)
5962

6063

@@ -88,6 +91,9 @@ class GenStats:
8891
class _WorkerRequest:
8992
max_new_tokens: int
9093
temperature: float
94+
top_p: float
95+
top_k: int
96+
seed: int
9197
stop: list[str]
9298
session_id: Optional[str]
9399
prompt_segments: Optional[list]
@@ -223,6 +229,9 @@ async def generate_stream(
223229
req = _WorkerRequest(
224230
max_new_tokens=options.max_new_tokens,
225231
temperature=options.temperature,
232+
top_p=options.top_p,
233+
top_k=options.top_k,
234+
seed=options.seed,
226235
stop=list(options.stop),
227236
session_id=session_id,
228237
prompt_segments=prompt.segments,

examples/llm_server/python/tests/test_contract.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ def test_chat_streaming_protocol(make_client):
9696

9797

9898
def test_request_params_forwarded_to_generation(make_client):
99-
# Contract behavior: the server must honor max_tokens/temperature.
99+
# Contract behavior: the server must honor all supported sampling controls.
100100
client, fake = make_client()
101101
client.post(
102102
"/v1/chat/completions",
@@ -105,10 +105,16 @@ def test_request_params_forwarded_to_generation(make_client):
105105
"messages": [{"role": "user", "content": "hi"}],
106106
"max_tokens": 7,
107107
"temperature": 0.1,
108+
"top_p": 0.8,
109+
"top_k": 32,
110+
"seed": 123,
108111
},
109112
)
110113
assert fake.captured_config.max_new_tokens == 7
111114
assert abs(fake.captured_config.temperature - 0.1) < 1e-6
115+
assert abs(fake.captured_config.top_p - 0.8) < 1e-6
116+
assert fake.captured_config.top_k == 32
117+
assert fake.captured_config.seed == 123
112118

113119

114120
def test_special_tokens_forwarded_to_worker_as_stops(make_client):

examples/llm_server/python/tests/test_sampling_params.py

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,9 @@ def test_n_greater_than_one_is_rejected(make_client):
3737
def test_unsupported_params_rejected(make_client):
3838
client, _ = make_client()
3939
for param in (
40-
{"top_p": 0.5},
41-
{"top_p": 2.0}, # > 1.0 is not a no-op either — only exactly 1.0/unset is
42-
{"seed": 42},
4340
{"reasoning_effort": "high"},
4441
{"frequency_penalty": 1.0},
4542
{"presence_penalty": -0.5},
46-
{"top_k": 40},
4743
{"logit_bias": {"123": 5.0}},
4844
{"response_format": {"type": "json_object"}},
4945
{"logprobs": True},
@@ -83,6 +79,23 @@ def test_temperature_range_rejected(make_client):
8379
assert r.json()["error"]["code"] == "invalid_value", param
8480

8581

82+
def test_sampling_parameter_ranges_rejected(make_client):
83+
client, _ = make_client()
84+
for param in (
85+
{"top_p": 0.0},
86+
{"top_p": -0.1},
87+
{"top_p": 1.1},
88+
{"top_k": -1},
89+
{"top_k": 2**31},
90+
{"seed": 0},
91+
{"seed": -1},
92+
{"seed": 2**63},
93+
):
94+
r = client.post("/v1/chat/completions", json=_body(**param))
95+
assert r.status_code == 400, param
96+
assert r.json()["error"]["code"] == "invalid_value", param
97+
98+
8699
def test_noop_output_contract_fields_accepted(make_client):
87100
# The default/no-op forms must NOT be rejected (don't break OpenAI clients
88101
# that send them explicitly).
@@ -133,13 +146,19 @@ def test_unsupported_tool_choice_rejected(make_client):
133146

134147

135148
def test_supported_params_accepted(make_client):
136-
# top_p=1.0 (no-op) and temperature/max_tokens must NOT be rejected; neither
149+
# Sampling controls and temperature/max_tokens must not be rejected; neither
137150
# should tool_choice "auto" / "none".
138151
client, _ = make_client()
139152
for temperature in (0.0, 1.0, 2.0):
140153
r = client.post(
141154
"/v1/chat/completions",
142-
json=_body(top_p=1.0, temperature=temperature, max_tokens=8),
155+
json=_body(
156+
top_p=0.9,
157+
top_k=40,
158+
seed=42,
159+
temperature=temperature,
160+
max_tokens=8,
161+
),
143162
)
144163
assert r.status_code == 200, temperature
145164
for choice in ("auto", "none"):

0 commit comments

Comments
 (0)