Compare commits

..

72 Commits

Author SHA1 Message Date
iomgaa dce063ebcd Merge branch 'fix/issue34-ocr-document': OCR whole-document parsing (1.3.11) 2026-10-22 03:49:05 -04:00
iomgaa 6850ed0d54 docs: finalize 1.3.11 changelog and register follow-up issue 37 2026-10-22 03:48:56 -04:00
iomgaa a64c680f64 docs: add pre-merge verification and review report for issue 34 2026-10-22 02:15:34 -04:00
iomgaa cb1e08f1f3 fix: close adversarial review findings on document parsing
Malformed download URLs are rejected before the second request, error bodies are read only up to a bound, the telemetry summary keeps structural dispatch for duck-typed results, and the BMP magic now requires a valid DIB header. Docs and design are aligned with the implementation, and new test wording is neutral.
2026-10-22 02:09:06 -04:00
iomgaa bb621cad4d fix: close independent verification findings on document parsing
Zip construction and member reads now share one narrow boundary so corrupt BZIP2, LZMA and version fields cannot leak native exceptions. Non-success statuses are read before classification, text-bearing blocks require lines, the size cap and reasoning contract are pinned by tests that fail under mutation, and the per-page timing claim is corrected to the observed range.
2026-10-22 01:12:27 -04:00
iomgaa d80feafb93 test: pin the reason behind rejected document packages
The outer exception boundary also absorbs accidental TypeError and KeyError, so asserting only the exception class could not tell an explicit validation from a lucky failure. Assert the reason for nested blocks and guard every rejection against an accidental cause.
2026-10-22 00:41:51 -04:00
iomgaa 6691e52195 docs: document parse_document and bump version to 1.3.11
Changelog, README (install pin, capability table, usage, deployment recipe, operations notes, known limits), env example, architecture, roadmap, the M3 design erratum and the CHSAnalyzer3 consumption notes.
2026-10-22 00:23:47 -04:00
iomgaa c4dd403e0f test: add live PDF document parsing checks
Slow, opt-in, serial single-page documents built in the test itself. Covers the document entrance, the PDF text-recognition fix, and that corrupt documents do not open the breaker.
2026-10-22 00:14:54 -04:00
iomgaa 42b7f0defc docs: register parse_document in telemetry schema and alignment targets 2026-10-22 00:07:54 -04:00
iomgaa 80bb8aba71 feat: add OcrClient.parse_document with full governance
Reuses the existing retry, breaker, limiter and telemetry loop; the transport capability is checked once before the statistics boundary. Legacy entrances keep their error text. Tests cover the document path through the real transport so parser exceptions, deadlines and probe bookkeeping are proven end to end.
2026-10-22 00:01:45 -04:00
iomgaa 61cace71c4 feat: add parse_document to the MonkeyOCR transport
Unrecognizable bytes are rejected before any request. The result package is downloaded as a stream with a cumulative size cap, and error statuses are classified before the size check. Parsing runs in a worker thread so cancellation and call deadlines stay effective. parse_layout shares the first protocol stage without behavior change.
2026-10-21 23:50:51 -04:00
iomgaa 0357d5afb3 feat: let document calls treat deterministic parse failures as rejected input
The service wraps every failure, including its own 400s, as a 500 with a parsing-failed detail. Only a fixed set of known bad-input markers is narrowed to a rejected request, and only when the caller passes markers; the shared default classification is unchanged.
2026-10-21 23:43:02 -04:00
iomgaa 3db7d9979c feat: parse document result packages from middle json only
Content comes from the middle json and its paired markdown; the content list is deliberately ignored because it escapes markdown characters. The external-data exception boundary covers both reading and structure parsing so no native exception escapes the OCR client. The layout parser is unchanged.
2026-10-21 23:35:47 -04:00
iomgaa c13c4fd28f fix: label PDF uploads by content instead of a fixed image name
The service dispatches on the upload filename extension. A PDF sent as image.jpg made /ocr/text return a success response with hallucinated text. Only PDFs are relabelled; everything else keeps the legacy image label.
2026-10-21 23:26:49 -04:00
iomgaa 398b16e136 feat: add OCR document types and ports
OcrDocumentElement extends the layout element with text; OcrDocumentResult stays independent because the parent's defaulted call_stats forbids a required markdown field. OcrDocumentTransport is a separate Protocol so existing OcrTransport implementers keep passing isinstance.
2026-10-21 23:20:53 -04:00
iomgaa b31df842a8 docs: fold plan review findings into OCR document plan 2026-10-21 23:14:22 -04:00
iomgaa 944b3f3798 docs: add implementation plan for OCR document parsing
Ten tasks covering types and ports, upload sniffing, package parsing, rejected-input classification, transport, client, registration, live checks, docs and verification.
2026-10-21 22:51:10 -04:00
iomgaa 1f08194b96 docs: record approval of OCR document design and rebuild wiki index
Index rebuild also registers the previously unlisted 1.3.10 design and plan.
2026-10-21 22:40:53 -04:00
iomgaa fb67b68440 docs: fold independent review findings into OCR document design
Adds live-service and server-source evidence, structure contract, exception and resource boundaries, corrected breaker analysis, and the Codex review record.
2026-10-21 22:16:50 -04:00
iomgaa 4117eae501 docs: add OCR document parsing design for issue 34
Root-cause analysis from live MonkeyOCR probes: upload label is not detected, output carries no content, and deterministic bad-input 500s are treated as transient. Proposes parse_document with content-sniffed upload type and middle.json-only extraction.
2026-10-21 21:55:01 -04:00
iomgaa 25a90ebd05 chore: merge 1.3.10 unified completion verdict (#30/#31) 2026-10-15 00:38:40 -04:00
iomgaa 1201a04449 chore: date 1.3.10 changelog for release 2026-10-15 00:38:19 -04:00
iomgaa abcdf6930e fix: address completion verdict review findings
- fidelity cacheability also requires a non-empty projected body, so
  think-tag-only answers are no longer cached
- a no-argument tool sent with arguments null only is a deterministic
  dialect: ResultInvalid tool_arguments_null instead of retried
- text mode treats choice index null as 0 and no longer lets False pose
  as 0
- null-only tool_calls elements no longer produce an empty tool_calls
  list; non-stream fidelity checks role before the verdict; warning
  paths are length-bounded
- docs: ARCHITECTURE 7.8 field counts, README cache/watchdog wording,
  CHANGELOG notes, review record in the design, hook note in the plan

Red: 8 new tests failed before this change (e.g.
test_openai_cacheable_requires_projected_body,
test_null_arguments_tool_is_result_invalid_stream,
test_text_mode_choice_index_null_is_zero[None-True]).
2026-10-15 00:28:20 -04:00
iomgaa 31942f55f1 docs: document completion verdict and bump version to 1.3.10
CHANGELOG 1.3.10 (unreleased) with behaviour-change and breaking
sections, README capability rows and a 1.3.10 section, ARCHITECTURE
section 15 plus 6.2/7 updates, schema doc for the stream_end column,
migration impact notes, ROADMAP entry, .env.example MISSING_DONE
semantics, and a revision pointer on the 1.3.9 design. Follow-ups
filed as #32 and #33.
2026-10-15 00:03:15 -04:00
iomgaa 779a9b29a3 feat: record stream end mode in telemetry
llm_calls gains a nullable stream_end TEXT column (auto backfill,
manual-mode trimming as in 1.3.8). TelemetryRecorder.record_llm_call
takes a new required keyword stream_end; the emitter reads it from the
response on attempt rows and writes NULL for failed, cache-hit and
terminal rows.

Red: 18 tests in tests/unit/test_telemetry.py and test_ports.py failed
against the pre-change source (missing column, 38-field signature,
stream_end absent from emitted rows).
2026-10-14 23:54:11 -04:00
iomgaa 48ba36dd94 fix: gate cache writes and reads on a shared cacheability verdict
Cacheable is now a strict subset of deliverable: salvaged streams,
content-less results and length/filter/unknown finishes are delivered
but never written, and the read path applies the same verdict to the
stored values before hit overrides (invalid stream_end or non-string
finish read as a miss; 1.3.9 fidelity entries fall back to the
completion's finish). Hits reset stream_end. The legacy
_has_completion_output helper is folded into _is_cacheable.

Red: 13 tests in tests/unit/test_cache.py failed before this change
(uncacheable results were written and replayed; new fields not reset).
2026-10-14 23:39:50 -04:00
iomgaa 39a319e34e fix: judge stream completion by finish reason and trailing usage
Both transport paths now share one verdict: a stream is complete on
[DONE] or on finish_reason followed by measured usage and a clean EOF
(both modes; missing_done only governs real truncation). Outcomes follow
the design's first-match table: upstream_error finishes stay transient,
fidelity mode delivers reasoning-only/length results, text mode raises
ResultInvalid for content-less length/filter endings, and non-stream
fidelity judges before building the snapshot so truncated tools reach
tool_truncated. Projections honour the reasoning alias, dialect warnings
are bounded, and responses carry finish_reason and stream_end.

Red: 39 of 46 tests in tests/unit/test_completion_transport.py failed
before this change (e.g. eof_after_finish raised 保真响应缺 [DONE];
reasoning-only length raised empty_completion; reasoning-only stream hit
the TTFT watchdog, re-confirmed by disabling alias liveness).
2026-10-14 23:31:54 -04:00
iomgaa 16743ca11d fix: merge unknown and null stream fields losslessly in completion accumulator
Null is treated as absent at every layer; unknown delta, tool and
function fields merge by OpenAI streaming conventions (strings concat,
label keys idempotent, indexed arrays by index) and conflicts drop the
key and record its path instead of failing the call. The accumulator
now tracks finish and usage-after-finish in both modes, exposes the
reasoning alias, and non-stream parsing splits shape checks from
snapshot construction.

Red: 20 of 47 tests in tests/unit/test_openai_completion.py failed
before this change (e.g. test_null_known_and_unknown_fields_are_absent
raised TransientError 未知 delta 字段; finish_seen/raw_message missing).
2026-10-14 23:21:05 -04:00
iomgaa e5ad67408a feat: add kernel completion verdict functions and response fields
Pure functions in types.py classify finish reasons, judge stream
completeness and outcome, and decide cacheability; TransportResult and
LLMResponse gain finish_reason and stream_end; snapshots accept
tool_calls null.

Red: tests/unit/test_completion_verdict.py failed at collection
(ImportError: _classify_finish et al. undefined) before this change.
2026-10-14 23:12:00 -04:00
iomgaa ab0e924849 docs: add implementation plan for unified completion verdict 2026-10-14 23:04:05 -04:00
iomgaa 607523ef5c docs: fold round-2 review fixes into completion verdict design 2026-10-14 22:46:56 -04:00
iomgaa 95104af2a5 docs: add unified completion verdict design for issues 30 and 31 2026-10-14 22:39:47 -04:00
iomgaa 5d2ad40336 chore: gate complexity only on functions touched by the change
Whole-tree radon blocking let 16 legacy C+ functions block every commit,
including docs-only ones. Touched functions still block; legacy debt is
reported as a warning.
2026-10-14 22:32:52 -04:00
iomgaa 22609c90d6 chore: merge approved 1.3.9 release preparation 2026-10-01 23:16:18 -04:00
iomgaa a0cf8546dc chore: prepare approved 1.3.9 release version 2026-10-01 23:08:37 -04:00
iomgaa ce10881eb0 docs: archive preserved wiki records and register hedge nodes 2026-10-01 23:03:51 -04:00
iomgaa bd82f16921 chore: merge 1.4.0 OpenAI message fidelity release 2026-10-01 14:48:47 -04:00
iomgaa 2c9c5a3aa7 test: forward response mode in transport helper 2026-10-01 14:48:29 -04:00
iomgaa 9185eab8c9 feat: preserve governed OpenAI tool messages in 1.4.0
Add faithful single-choice chat results, tool stream activity, error-frame retries, and isolated protocol caching.

Reject empty cached completions with the same value predicate as transport, add regression coverage, and adopt Apache-2.0.
2026-10-01 14:32:19 -04:00
iomgaa 0e29deac1a docs: record 1.3.8 release gate results 2026-09-14 03:36:06 -04:00
iomgaa b0232b7466 merge: release 1.3.8 net generation time and mid-stream stall hedging 2026-09-14 03:04:04 -04:00
iomgaa cf6e3fa008 chore: release 1.3.8 2026-09-14 03:03:39 -04:00
iomgaa a2a00c0ef2 test: sync PG telemetry integration helpers with 1.3.8 stall columns
_minimal_fields and the pre_135 acceptance dicts missed the two new
columns, KeyErroring against migrated 39-column physical tables
(caught by the pre-merge integration gate; production path is
signature-gated and unaffected). Verifier gaps P2-1/P2-2 closed:
full suite 1764 passed, PG 30 passed, lint green.
2026-09-14 03:01:40 -04:00
iomgaa 8489b8ea50 docs: resolve branch review note (final-state comment for content_event wiring) 2026-09-14 02:28:59 -04:00
iomgaa ca8743bf33 docs: document net generation time and mid-stream stall hedging 2026-09-14 02:13:55 -04:00
iomgaa 2ac05a20b6 feat: add opt-in mid-stream stall hedging for chat
HEDGE__STALL_AFTER_S triggers a cross-source hedge when the primary
leg stalls mid-stream (independent of HEDGE__AFTER_S); two-phase
hedge window with event recheck discipline; watchdog-killed primary
converges on the surviving hedge leg. STREAM__STALL_THRESHOLD_MS
(1000ms default) adjustable via env/settings/constructor. Assembly
guards: ValueError on never-opening windows, embedding/OCR scope
warning (B4). Batch D 14 tests red-to-green, 1688 unit+contracts.
2026-09-14 01:52:10 -04:00
iomgaa e0b0be09a7 feat: add stall_ms and stall_count telemetry columns
Third approved port change: TelemetryRecorder.record_llm_call gains
stall_ms/stall_count (assembly gate derives from this signature).
Schema five edit points (both DDLs, both backfill decls, COLUMNS)
with INTEGER nullable columns appended last; telemetry_schema_sql
regenerates from the same constants untouched. Emitter forwards the
pair from retry._emit on the success branch only (cancelled hedge
loser rows stay NULL by construction); cache-hit and terminal rows
pass explicit None. Both **fields recorders pick the columns up with
zero signature change.
2026-09-14 01:16:33 -04:00
iomgaa 53bcf43172 feat: redefine generation_ms as net generation time with stall attribution
CallStats gains stall_ms/stall_count (None = no stall observation);
winner sample triple (wall, stall_ms, stall_count) converted once in
_attempt success branch; net = max(0, wall - stall) converged into the
shared _record_winner_sample used by both the single-path guard and
hedge winner registration. Two-fail hedge rounds record nothing
(no winner to attribute). Design section 4.2; issues #27.
2026-09-14 00:56:54 -04:00
iomgaa 901a193514 feat: add a content event and stall accounting to the transport port
T1(计划 §4):观测原语与端口契约(issue #27/#28 底层)。

- Transport.complete 加必填 kw content_event(顺序在 first_token_event
  后,无默认值,同款约定):流式每增量 chunk 置位一次,非流式/对冲路
  恒不置位,None = 不观测
- TransportResult 增 stall_ms/stall_count(带默认 None):超阈值相邻
  增量 chunk 沉默总额与段数,chunk<2/非流式 → None,尾部不计
- SourceConfig 增 stall_threshold_ms(默认 1000,正整数拒 bool,
  _validate_watchdog 同款落点)
- _complete_stream 记增量 chunk 时间戳(空 chunk/usage 帧/keepalive
  不产生记录点)+ 模块级纯函数 _stall_from_chunk_times(严格大于
  阈值才减);max_inter_token_ms 原语义一字不动
- §2.2 同步清单全量:11 实现 + 11 调用点(重跑 grep 复核零遗漏)
- 批次 A 12 用例先红(TypeError/ImportError/AttributeError)后绿,
  证据 tests/outputs/138/t1/
2026-09-14 00:35:35 -04:00
iomgaa 6f3a620c73 docs: fix plan per independent review (B1 emitter fake sync, B2 guard return contract, N1-N5) 2026-09-14 00:10:37 -04:00
iomgaa 929d60e501 docs: add 1.3.8 implementation plan and B4 design errata
B4: assembly-time non-streaming hedge warning operationalized to
embedding/OCR scopes only (chat stream is per-call, no assembly
signal); AFTER_S same-gap backfill included. Plan T1-T5 with real
grep sync lists and fail-first test batches.
2026-09-13 23:46:34 -04:00
iomgaa 97de4104c6 docs: finalize streaming stall design with independent review errata
B1 chunk = any delta (content or reasoning), B2 telemetry plumbing
via explicit _emit pass, B3 orchestration entry gate for independent
stall hedging; N1-N9 advisory fixes merged. Human decisions recorded
in design section 9.
2026-09-13 23:20:52 -04:00
iomgaa 03cbf4c8db docs: add approved streaming stall measurement and mid-stream hedging design
Findings: industry paradigm research (watchdog-retry is the norm,
mid-stream hedging has no public precedent; pi-tps threshold-based
stall deduction; Gregg off-CPU paired reporting; vLLM exclusion
clauses). Design: generation_ms redefined as net generation time
(wall minus all inter-chunk stalls over threshold), CallStats gains
stall_ms/stall_count, HEDGE__STALL_AFTER_S mid-stream hedge
independent of HEDGE__AFTER_S. Issues #27 #28.
2026-09-13 22:53:00 -04:00
iomgaa a28d94451e docs: record 1.3.7 release gate results 2026-09-10 15:19:42 -04:00
iomgaa a04d87e8d7 merge: release 1.3.7 hedged requests and bare generation time 2026-09-10 14:52:08 -04:00
iomgaa c60011b034 chore: release 1.3.7 2026-09-10 14:51:53 -04:00
iomgaa 9f7d407120 fix: skip hedge dispatch when primary finishes during admission
Address branch review findings 1-4 on feature/1.3.7-hedged-requests:

- src/polygateway/middleware/retry.py: recheck primary.done() after
  hedge admission in _attempt_hedged; release the hedge permit via
  settle_and_release(permit, 0) (release_probe for probe entries) and
  adjudicate the primary directly instead of firing a billable HTTP
  request that would be cancelled immediately
- src/polygateway/config.py: check_hedge_assembly raises a hedge-located
  ValueError for empty sources instead of a bare min() error
- tests/unit/test_hedge.py: pin that an injected FakeClock jump of 10^6
  seconds does not trigger hedging (design section 8); pin pick(exclude)
  counting no gate_rejections and leaving reasons untouched; pin silent
  hedge abandonment when the candidate circuit is open; deterministic
  regression for the admission-window race (BlockingLimiter harness)
- tests/unit/test_config.py: assert the empty-sources guard message
  locates the hedge key

Red-to-green evidence in tests/outputs/137/review-fixes/
2026-09-10 14:47:40 -04:00
iomgaa 0572611af7 docs: document hedged requests and bare generation time 2026-09-10 14:12:30 -04:00
iomgaa fe616cf91d docs: add approved hedged requests design and implementation plan 2026-09-10 14:04:31 -04:00
iomgaa adc069447a feat: add opt-in cross-source hedged requests for chat 2026-09-10 14:03:25 -04:00
iomgaa 463eca380d feat: expose bare generation time and hedge flags in CallStats
CallStats gains hedges/generation_ms/hedge_won (all defaulted, appended
after total_latency_ms); _CallContext counts them via record_generation
(overwrite for chat/OCR, accumulate for embedding batches) and
register_hedge, and snapshot carries them out. All three _attempt
implementations time only the transport call itself on the same injected
clock as total_latency_ms; the chat sink is recorded by the orchestrator
so hedge winner attribution stays with T3. Hedge counters stay 0/False
until the T3 orchestration lands.

Red-green evidence: tests/outputs/137/t2/ (10 new tests AttributeError
red, then green; full unit+contracts 1621 passed).
2026-09-10 13:28:51 -04:00
iomgaa 0a6d6225db feat: add a required first-token event to the transport port
Transport.complete gains the keyword-only first_token_event (no default,
per the port convention): streaming sets it on the first delta, the
non-streaming path accepts it but never sets it, None means the caller
does not observe the first token. All fake/wrapping transports and the
three direct call sites follow the signature; the e2e wrapper forwards.

Red-green evidence: tests/outputs/137/t1/ (batch A TypeError red, then
147 file tests + 1550 unit tests green).
2026-09-10 13:16:38 -04:00
iomgaa 166b2865d0 docs: record 1.3.6 release gate results and matrix exemption 2026-09-10 11:31:27 -04:00
iomgaa b0ab39edbf docs: scope the slow release gate by diff intersection 2026-09-10 11:30:40 -04:00
iomgaa a07a6b096b merge: release 1.3.6 call deadline and cancellation settlement 2026-09-10 05:22:35 -04:00
iomgaa 53d2f089e1 chore: release 1.3.6 2026-09-10 05:22:24 -04:00
iomgaa 5e2c35a812 test: pin the expiry window and close settlement review gaps 2026-09-10 05:20:56 -04:00
iomgaa b1bc06e2b1 docs: document the optional call deadline and cancellation settlement
The deadline governs waiting, not the moment a call returns: cleanup
still runs in finally, so the return time is the deadline plus the
cleanup cost (5-7x the deadline in the measured fixture). An expiry
therefore does not mean nothing was produced or nothing was billed.
Leaving the key unset keeps 1.3.5 semantics verbatim, which also keeps
its two long waits: a pure 429 sequence can still wait for a long time,
and a large finite Retry-After is still slept in full because the
library deliberately does not clamp the hint with backoff_max_s.

- CHANGELOG unreleased section states those three sentences, records the
  cancellation settlement change (an attempt cancelled after the port
  started but before the settlement is known keeps its reservation at
  the source estimate: over-charge rather than refund something the
  upstream may already have billed; known settlements and unclassified
  escapes are untouched) and warns that except GatewayUnavailableError
  does not catch CallDeadlineExceeded
- README in four places: the capability table, the exception handling
  example, the "which exceptions reach the caller" table and the error
  model section, which now spells out the remaining large-but-finite
  Retry-After wait
- .env.example documents LLM__CALL_DEADLINE_S as commented out
- new findings file indexes the red/green evidence, the commands and
  their exit codes, what was not run and who covers it, and repeats the
  three residual risks

No version bump, no tag, no release: those belong to the release
checklist.
2026-09-10 04:33:55 -04:00
iomgaa da77b123ec fix: ignore non-finite Retry-After hints
A gateway that answers 429 with Retry-After: inf (or 1e999, which float()
happily rounds to inf) used to reach backoff_delay as retry_after_s=inf.
max(delay, retry_after) then picked it, and since the library deliberately
does not clamp the hint with backoff_max_s, the attempt slept forever.

- _parse_retry_after now rejects non-finite values via math.isinf and
  returns None, so the call falls back to plain exponential backoff
- it emits exactly one warning carrying the source name and the verdict
  word retry_after_not_finite, never the raw header: under a 429 storm an
  echoed header drowns the real signal and does not help localisation
- source_name becomes a required keyword-only argument; the function is
  private, so no default is given and a missed call site fails loudly
  instead of silently dropping the source identity from the warning
- nan keeps flowing through the existing seconds > 0 semantics; no new
  branch, no reordering of the parse
2026-09-10 04:25:09 -04:00
iomgaa 9474c76ab0 feat: add an optional per-call wall-clock deadline
Give one logical call an optional hard wall-clock boundary (issue #22).
Leaving it unset keeps 1.3.5 behaviour verbatim: the timeout context is
never entered when deadline_s is None.

- new deadline.py: ensure_call_deadline() range check (None or a finite
  positive number; bool/0/nan/inf and out-of-range ints are rejected as
  ValueError so OverflowError never leaks) plus with_call_deadline(),
  which distinguishes an expiry from a TimeoutError raised by the body
  or its cleanup via a local-variable identity comparison rather than
  cm.expired() alone
- new CallDeadlineExceeded: deliberately outside the four categories and
  not a GatewayUnavailableError, and carries no retry_after_s
- new {SCOPE}__CALL_DEADLINE_S key, guarded on the env, direct
  construction and dataclasses.replace paths
- three clients take a call_deadline_s constructor argument and a
  keyword-only per-call override on chat/embed/recognize_text/
  parse_layout; None inherits the assembled value
- validation runs before the awaitable is created, so an illegal value
  cannot strand an un-awaited coroutine
- one embed call shares a single deadline across all of its batches
- import-linter gains a polygateway.deadline layer

- cover where the deadline lands: backoff sleep, admission polling,
  the structured re-ask ladder and embedding's batch loop, plus the
  empty-texts early return that stays outside it
- cover what an expiry costs: exactly one terminal_failure row carrying
  error_type=CallDeadlineExceeded, a cancelled attempt row sharing its
  logical_call_id, cleanup that outlives the deadline (lower bound only)
  and an already-billed success being discarded
- pin the injected clock as orthogonal: a 10^6 second jump never expires
  a call, yet total_latency_ms still reads that clock
2026-09-10 04:14:44 -04:00
iomgaa 1ff83bbfe0 fix: settle cancelled attempts against the source estimate
取消发生在"端口已开始、结算尚未确定"时,原先按 settle(0) 把入场预扣整笔
退还,等于把可能已被上游计费的用量退回闸里;启用调用期限后库自身会常规性
触发该路径,故先修记账再启用。

改动只落在取消路径的取值上(设计 §6.3 矩阵 S3/S5/S7):
- actual 初值保持 0,另设函数内局部阶段变量 settlement_known(不进任何签名);
- 成功路径算出用量后置位,真实 usage 恰为 0 同样算"已知",不被取消覆写;
- 已处理领域失败分支把结算决定前移到其第一个 await 之前(同级
  except CancelledError 接不住本块 await 上的取消,它直穿 finally),
  故 SourceDead 的既有 0 在取消下被保住,瞬时失败仍是 est,值与 1.3.5 逐字相同;
- 未被四分类接住的异常不经上述分支,仍按 0 退全款(S8,本版不扩大语义);
- OCR 的 0 token 是事实而非未知,settle(0) 不变,只补注释。

取消窗口一律用真实 asyncio.Event 钉死(不再 sleep 撞窗口),并加防越界回归:
RuntimeError 逃逸仍结 0、真实 usage 为 0 的成功仍结 0。
2026-09-10 00:48:07 -04:00
iomgaa 6c640fcca3 docs: record approved call deadline design and plan 2026-09-10 00:33:13 -04:00
92 changed files with 15369 additions and 293 deletions
+28 -3
View File
@@ -41,10 +41,35 @@ for CODE_DIR in "${CODE_DIRS[@]}"; do
fi fi
fi fi
# 复杂度只对"本次改动触及的函数"硬拦: 全量拦截会让存量债务挡住一切提交
# (含纯文档提交),久之只剩绕过一条路;存量 ≥C 函数降为告警,随触及随还。
# 用 HEAD 对比工作区+暂存区: 钩子在 `git add && git commit` 同一命令里先于 add 运行
if command -v radon &> /dev/null; then if command -v radon &> /dev/null; then
RADON_OUTPUT=$(radon cc "$CODE_DIR" -n C -s 2>&1 || true) mapfile -t CHANGED_PY < <(git diff HEAD --name-only --diff-filter=AM -- "$CODE_DIR" | grep '\.py$' || true)
if echo "$RADON_OUTPUT" | grep -qE '^\s+[FMC]\s'; then if [[ ${#CHANGED_PY[@]} -gt 0 ]]; then
ERRORS+="[radon] 存在圈复杂度 ≥ C 的函数:\n$RADON_OUTPUT\n" TOUCHED=$(git diff HEAD -U0 -- "${CHANGED_PY[@]}" | python3 -c '
import json, re, subprocess, sys
hunks, path = {}, None
for line in sys.stdin:
if line.startswith("+++ "):
path = line[6:].strip() if line.startswith("+++ b/") else None
m = re.match(r"@@ -\S+ \+(\d+)(?:,(\d+))? @@", line)
if m and path:
start, count = int(m.group(1)), int(m.group(2) or 1)
hunks.setdefault(path, []).append((start, start + max(count, 1) - 1))
data = json.loads(subprocess.run(["radon", "cc", "-j", "-n", "C", *hunks], capture_output=True, text=True).stdout or "{}")
for path, blocks in data.items():
for b in blocks if isinstance(blocks, list) else []:
if any(s <= b["endline"] and e >= b["lineno"] for s, e in hunks.get(path, [])):
print("%s:%s %s - %s (%s)" % (path, b["lineno"], b["name"], b["rank"], b["complexity"]))
' 2>&1 || true)
if [[ -n "$TOUCHED" ]]; then
ERRORS+="[radon] 本次改动触及的函数圈复杂度 ≥ C:\n$TOUCHED\n"
fi
fi
LEGACY_COUNT=$(radon cc "$CODE_DIR" -n C 2>/dev/null | grep -cE '^\s+[FMC]\s' || true)
if [[ "$LEGACY_COUNT" -gt 0 ]]; then
WARNINGS+="[radon] $CODE_DIR/ 存量 ${LEGACY_COUNT} 个函数圈复杂度 ≥ C(未被本次改动触及,不阻塞)。\n"
fi fi
fi fi
+32 -1
View File
@@ -31,7 +31,7 @@ LLM__QWEN__1__TIMEOUT_S=120
# 模型不支持所配档位时报错并列出它真正支持的档(库带能力表,含出处与实测日期) # 模型不支持所配档位时报错并列出它真正支持的档(库带能力表,含出处与实测日期)
# LLM__QWEN__1__EFFORT_FALLBACK=error # 档位打空时: error(默认,报错) | nearest(映射到最近的档) # LLM__QWEN__1__EFFORT_FALLBACK=error # 档位打空时: error(默认,报错) | nearest(映射到最近的档)
# 默认报错的理由是钱: 静默的 medium→max 在部分模型上是数倍账单;nearest 等距取弱侧 # 默认报错的理由是钱: 静默的 medium→max 在部分模型上是数倍账单;nearest 等距取弱侧
# LLM__QWEN__1__MISSING_DONE=retry # SSE 缺 [DONE]: retry(默认) | salvage # LLM__QWEN__1__MISSING_DONE=retry # SSE 真截断(无 [DONE] 且无 finish+其后实测 usage): retry(默认) | salvage;1.3.10 起 finish+实测 usage+干净关闭的无 DONE 流恒视为完整
# LLM__QWEN__1__TRUST_ENV=true # false = 绕过本地代理(LAN 直连) # LLM__QWEN__1__TRUST_ENV=true # false = 绕过本地代理(LAN 直连)
# LLM__QWEN__1__EXTRA_BODY={"temperature":0} # 本源恒定的采样参数(JSON 对象串) # LLM__QWEN__1__EXTRA_BODY={"temperature":0} # 本源恒定的采样参数(JSON 对象串)
# 并入请求体,优先级低于 chat(overlay=...);受控实验固定解码用它,免得漏传 # 并入请求体,优先级低于 chat(overlay=...);受控实验固定解码用它,免得漏传
@@ -69,6 +69,24 @@ LLM_CIRCUIT_BREAKER_COOLDOWN=60 # 或 LLM__BREAKER__COOLDOWN_S
# ── 在几毫秒内死掉且 MAX_ATTEMPTS 一格用不上。wait 不削弱保护(等待期照样 # ── 在几毫秒内死掉且 MAX_ATTEMPTS 一格用不上。wait 不削弱保护(等待期照样
# ── 不发请求),只是把最坏墙钟拉长到 BACKPRESSURE__STALL_WINDOW_S ── # ── 不发请求),只是把最坏墙钟拉长到 BACKPRESSURE__STALL_WINDOW_S ──
# LLM__CIRCUIT_OPEN=fail_fast # 熔断开路: fail_fast(默认) | wait # LLM__CIRCUIT_OPEN=fail_fast # 熔断开路: fail_fast(默认) | wait
# LLM__CALL_DEADLINE_S= # 一次逻辑调用的墙钟硬边界(秒);缺省不设 = 不启用
# ── 治理对象是"等待"(退避/配额轮询/熔断冷却/结构化重问/embedding 分批共享一份),
# ── 不是单次 HTTP 超时(那是 TIMEOUT_S)。清理仍在 finally 跑完: 返回时刻 = 期限 + 清理耗时,
# ── 且到期 ≠ 未产出、≠ 未计费。到期抛 CallDeadlineExceeded(不属四分类、
# ── 不属 GatewayUnavailableError 族、无 retry_after_s);非法值(0/负/nan/inf)装配期报错 ──
# LLM__HEDGE__AFTER_S= # 长尾对冲触发阈值(秒);缺省不设 = 关闭(仅 chat 生效)
# LLM__HEDGE__MAX_EXTRA=1 # 每次逻辑调用最多对冲路数;v1 仅单路生效(>1 仅装配期 warning)
# ── 触发语义: 流式 = 超阈值且首 token 未至(不误杀慢生成);非流式 = 纯总时长阈值(无中途信号,
# ── 「挂起 vs 慢生成」物理不可分,建议取源 p50 的数倍)。对冲向异源并发再发一次,走完整限流/熔断准入,
# ── 拿不到配额静默放弃。成本含义: 开启即用配额换延迟——触发窗口内 in-flight 翻倍,输家被取消后按 est
# ── 保留预扣(不喂熔断),且输家可能已被上游计费、取消止不住。须与 CALL_DEADLINE_S 组合时强制阈值 < 期限 ──
# LLM__STREAM__STALL_THRESHOLD_MS=1000 # 中段沉默计量阈值(毫秒,缺省 1000):相邻增量 chunk 间隔严格大于它才计入
# ── stall_ms/stall_count 并从 generation_ms 净口径中扣除;仅改计量口径,不改任何行为 ──
# LLM__HEDGE__STALL_AFTER_S= # 中段沉默对冲触发阈值(秒);缺省不设 = 关闭(仅流式 chat 生效)
# ── 语义: 主路首 token 之后相邻增量 chunk 沉默超此阈值 → 向异源并发再发一路;独立于 HEDGE__AFTER_S 生效,
# ── 仅设它且请求非流式时逐字走单路。成本: 下游实测沉默 >20s 占 19.0%,触发率远高于首 token 对冲——
# ── 开启即用配额换延迟,输家可能已被上游计费;无业界公开先例(超业界现状能力)。守卫: ≥ 最小源有效
# ── inter-token 超时(对冲窗永不开)或 ≥ CALL_DEADLINE_S → 装配期 ValueError;单源 scope 仅 warning ──
# ══ 装配选择(PGW_*)══ # ══ 装配选择(PGW_*)══
PGW_LIMITER_BACKEND=memory # memory | redis(redis 需 REDIS_URL;多进程 worker 必须 redis) PGW_LIMITER_BACKEND=memory # memory | redis(redis 需 REDIS_URL;多进程 worker 必须 redis)
@@ -175,6 +193,15 @@ PGW_TELEMETRY_BACKEND=none # sqlite | postgres | none(必填)
# OCR__MONKEY__2__TIMEOUT_S=300 # OCR__MONKEY__2__TIMEOUT_S=300
# OCR__MONKEY__2__TRUST_ENV=false # OCR__MONKEY__2__TRUST_ENV=false
# OCR__RETRY__MAX_ATTEMPTS=3 # per-scope 韧性键与 LLM scope 同一套 # OCR__RETRY__MAX_ATTEMPTS=3 # per-scope 韧性键与 LLM scope 同一套
# 整份文档解析(parse_document,1.3.11):建议用**独立 scope** 指向同一服务,自带超时/重试/期限:
# OCR_DOC__MONKEY__1__BASE_URL=http://10.77.0.20:7866
# OCR_DOC__MONKEY__1__API_KEY=none
# OCR_DOC__MONKEY__1__MODEL=monkey-ocr
# OCR_DOC__MONKEY__1__TIMEOUT_S=600 # ≥ 最大页数 × 4s × 余量(实测 1.5–4 s/页,共用 GPU 争用再乘 3–5 倍);不是总耗时上界
# OCR_DOC__MONKEY__1__MAX_CONCURRENCY=2 # 服务为 sync-only 后端时取 1(每实例 /parse 全局串行)
# OCR_DOC__MONKEY__1__TRUST_ENV=false
# OCR_DOC__RETRY__MAX_ATTEMPTS=2 # 超时/GET 失败的重试会重新 POST 整份文档重算,压低
# OCR_DOC__CALL_DEADLINE_S=900 # 单份硬上限;须 ≤ PGW_LEASE_TTL_S(缺省 1500)
# ══ SOAK_OCR scope(P7 OCR 压测;tools/soak/run_soak.py --scenario P7 --scope SOAK_OCR)══ # ══ SOAK_OCR scope(P7 OCR 压测;tools/soak/run_soak.py --scenario P7 --scope SOAK_OCR)══
# 双真实实例 + 黑洞(连接超时)+ 坏端口(连接拒绝)故障池; # 双真实实例 + 黑洞(连接超时)+ 坏端口(连接拒绝)故障池;
@@ -182,3 +209,7 @@ PGW_TELEMETRY_BACKEND=none # sqlite | postgres | none(必填)
# SOAK_OCR__GLOBAL__MAX_CONCURRENCY=16 # SOAK_OCR__GLOBAL__MAX_CONCURRENCY=16
# SOAK_OCR__GLOBAL__RPM=300 # SOAK_OCR__GLOBAL__RPM=300
# SOAK_OCR_FAULT_SOURCES=monkey_3,monkey_4 # SOAK_OCR_FAULT_SOURCES=monkey_3,monkey_4
# OpenAI 保真模式是 per-call response_mode="openai",无新增环境键。
# raw-only reasoning_effort 放调用 overlay;对应源不得设置 ENABLE_THINKING 或
# REASONING_EFFORT,请求 reasoning_effort=None 只继承源配置,不撤销源级意图。
+207
View File
@@ -1,5 +1,212 @@
# Changelog # Changelog
## 1.3.11(2026-10-22)
#34: OCR 新增整份文档解析 `OcrClient.parse_document`——CHSAnalyzer3 要把几百篇医学论文 PDF 整份转成带页码的正文与表格,而库只会返回版面框,且 PDF 被写死的 `image.jpg` 标签当成图片上传。两个症状同根: 库把 OCR 输入建模成「一张图 + 写死的标签」、输出建模成「只有框」,没有「文档」这个概念。设计 `research-wiki/designs/2026-10-21-issue34-ocr-document-design.md`。
### 新增
- **`OcrClient.parse_document(document, *, session_id, parent_call_id, tenant_id, meta, call_deadline_s) -> OcrDocumentResult`**: PDF 或图片整份交给 MonkeyOCR,返回逐块 `OcrDocumentElement`(继承 `OcrLayoutElement`,仅多一个 `text`:`text`/`title` 纯文本、`interline_equation` 裸 LaTeX、`table` HTML、`image` 空串)、`page_sizes` 与整篇 `markdown`。治理链路(限流/熔断/重试/换源/遥测/调用方维度/期限)复用 `parse_layout` 的同一循环。
- 公共类型 `OcrDocumentElement` / `OcrDocumentResult`(包根导出);端口 `OcrDocumentPort`;transport 协议 `OcrDocumentTransport`(**独立于 `OcrTransport`**,不动旧 `runtime_checkable` Protocol 的成员,既有实现与测试替身不受影响)。
- `CallOperation` / 遥测 `operation` 列新增取值 `parse_document`(无 DDL、无新列);占位 `<ocr:document image_bytes=N>`,response 摘要 `<document elements=N pages=M md_chars=K>` **永不写入正文**。
- 内容只取自 `_middle.json` 与配对的 `.md`,**有意不读 `_content_list.json`**(它是展示格式,星号、反引号、波浪线、美元符会被转义,实测 134 个文字块中 23 个含 `\$`)。
### 修复
- **`recognize_text(PDF)` 的静默错误结果**: 此前一切上传都标 `image.jpg`,而 MonkeyOCR 按扩展名分派。PDF 走图片分支时 `/ocr/text` 返回 `success:true` 与幻觉文本(实测 `"The image contains a single character: 1"`),下游完全无感。上传标签现在按**内容**判定(图片魔数优先,再看前 1KB 内的 `%PDF-`),三个方法共用一处;非 PDF 一律沿用 `image.jpg`(字节级不变)。
### 行为变更
- **`parse_layout(PDF)` 的上传分支改变**(图片数据集 → PDF 数据集)。差分实测: 文本型与无文字层的位图型 2 页 PDF,`_middle.json` / `_content_list.json` / `.md` **逐字相同**;**旋转页、带批注页未测,不构成保证**。该路径无已知消费者(医院线只传图片)。
- **`parse_document` 对坏输入的处置**(仅新路径;共享分类不变): 无法识别的字节本地拒绝、不发请求(`RequestRejectedError`,不带 `status_code`);服务把一切失败包成 `500 "Parsing failed: …"`,其中已知的确定性坏输入(`Failed to open stream` / `Cannot open empty stream` / `cannot save with zero pages` / `is no PDF`)归 `RequestRejectedError`(`status_code=500`)——**不重试、熔断记成功不计窗口**。其余 500 仍是瞬时错误。标记集合可经 `MonkeyOcrTransport(deterministic_failure_markers=...)` 覆盖。
- 结果包解析的外部数据异常边界统一落 `ResultInvalidError`(含加密成员、未知压缩方法、巨大整数、深嵌套 JSON),不漏出原生异常——漏出会让客户端既不写终态遥测行、又不归还半开探针。
- 文档路径流式下载并限制整包 256 MiB;`_middle.json` ≤ 64 MiB、`.md` ≤ 16 MiB;解析在线程里执行,取消与 `call_deadline_s` **基本不被占住**(`json.loads` 持有 GIL:实测 54 MiB 的 middle 解析约 1 秒、事件循环最大停顿约 0.3 秒;取消后线程自然收尾、占用默认 executor,正常大小是毫秒级)。`parse_layout` 的口径(bbox 整数化退化校验、`page_idx` 回退、无体积上限)**保持原样**。
- **共用的第一段协议新增 `download_url` 校验**: 服务响应里的畸形 `download_url`(控制字符、坏方括号、越界端口)此前会让 `httpx.InvalidURL` 等原生异常漏出 `OcrClient`(不写终态遥测行、半开探针不归还),现归瞬时错误、不发第二段请求。`parse_layout` 共用这一处,属顺带修复;**不限制主机**(基线接受绝对地址)。
- **嗅探副作用**: 前 1KB 内含 `%PDF-` 字样的非 PDF 文本在 `recognize_text` / `parse_layout` 上的上传标签由 `image.jpg` 变为 `doc.pdf`;BMP 魔数现要求 DIB 头大小字段合法(不再单凭 `BM` 两字节)。
- 文档路径读取错误响应体只读前 64 KiB(摘要只需要几 KiB),不再把巨大的错误体整个读进内存。
### 已知限制与运维须知(详见 README「1.3.11 OCR 整份文档解析」)
- **服务端丢弃脚注、页眉页脚、页码**,表格脚注里的显著性/缩写说明不会出现在结果里。
- **`timeout_s` 不是总耗时上界**(逐次网络操作的超时);长文档的硬上限靠 `call_deadline_s`,并应 `≤ lease_ttl_s`。库内无基于延迟的熔断,长文档不会被判慢。
- 若 MonkeyOCR 部署为 sync-only 后端(出厂默认 `lmdeploy`),`/parse` 每实例全局串行,长文档会队头阻塞同实例上医院线的请求;请向运维确认 backend。
- 多行/多 span 文本块、`inline_equation` span、嵌套 caption/footnote 子块三条路径无真实样本,仅由构造 fixture 测试。
- 后续: #35(共享分类对确定性坏输入 500 仍归瞬时)、#36(`recognize_text` 不校验服务响应的 `success`)、#37(`parse_layout` 结果包解析仍漏出原生异常、`download_url` 不限制主机)。
## 1.3.10(2026-10-15)
#30/#31 同根修复: 1.3.9 以 `[DONE]` 存在、正文非空、字段白名单判定成功,上游的正常结局被当瞬时错误重试到耗尽(magpie/glm 不发 `[DONE]` 时单次调用挂 1216s;只有思考的 length 截断每次写满 max_tokens 后重抽)。本版改以上游 `finish_reason` 与传输完整性为准,流式/非流式/缓存读写共用一份判定。设计 `research-wiki/designs/2026-10-14-issue30-31-completion-verdict-design.md`。
### 修复
- **缺 `[DONE]` 但完整的流**(#31,**两种模式**): finish_reason 已到、其同帧或之后收到实测 usage、连接干净关闭 → 完整交回,用量记 measured,`stream_end="eof_after_finish"`。文本模式默认 `MISSING_DONE=retry` 原本同样会重试到耗尽;该键现在只管真截断(无 finish 或 finish 后无实测 usage)。
- **只有思考的 length 截断**(#30): 保真模式原样交回(content 空、思考完整、finish_reason=length),不再重抽。
- **`null` 与未知字段**(#30): 任意层 `null` 视为缺省(`refusal: null`、`tool_calls: null` 不再判失败,**非流式同样修复**);保真模式未知字段按 OpenAI 流式惯例无损合并(字符串拼接、对象递归、带 `index` 数组按 index 合并、`index/type/id/format` 同值幂等),Gemini `extra_content` 一类工具层扩展可原样回传。
- **`reasoning` 别名**: vLLM/OpenRouter 的 `reasoning` 字段计入活性(此前只有思考的流会被 TTFT 看门狗误杀),文本模式无非空 `reasoning_content` 时投影为 `thinking`。
- **截断结果不再写缓存**: 文本 salvage 结果此前会被缓存并在 TTL 内反复回放。
### 行为变更
- **文本模式 length/过滤且无正文 → `ResultInvalidError`**(原 `TransientError` 重试到耗尽): 不重试、不换源、熔断记成功不计窗口;**不经 `AllSourcesExhausted` 包装**,直接到调用方。原因码见异常消息(`length_without_content` / `filtered_without_content`)。文本模式"只有思考 + stop"维持瞬时(证据收集中)。
- **finish_reason 为 `network_error`/`error`**(GLM 等上游自报失败)即使有正文也判瞬时。
- **保真模式**: 只有思考/拒答/空白 + 明确 finish(非上游错误)→ 原样交回;length 截断的半截工具(缺 id/name)→ `ResultInvalidError` `tool_truncated`;无参函数全程只发 `arguments: null`(其余字段齐全)→ `ResultInvalidError` `tool_arguments_null`(确定性方言,交付值不改写)。
- 文本模式 `choice.index: null` 视为 0(此前整帧被丢弃);`tool_calls` 中全为 `null` 的元素按 `tool_calls: null` 处理,不产出会被上游拒收的空数组。
- **缓存**: 只有自然结束(stop/tool_calls/无 finish 的旧口径)且有正文的结果写入;length/过滤/未登记 finish 值、salvaged、空正文均交付但不缓存,读到此类条目按 miss。未登记 finish 值按源告警一次。
- **活性口径增补**: 未知字段的非空字符串片段(如 `reasoning`)算进展,`index/type/id/format` 等标签键不算;`stall_ms` 计量随之覆盖。
### 新增
- `LLMResponse` / `TransportResult` 末尾追加 `finish_reason: str | None`、`stream_end: StreamEnd | None`(`done` / `eof_after_finish` / `salvaged`;非流式、缓存命中为 None);包根导出 `StreamEnd`。
- 遥测 `llm_calls` 末尾追加可空列 `stream_end`(INSERT 字段 38 → 39,物理列 39 → 40;旧行 NULL 不回填;auto 档幂等补列,manual 档裁剪写入并点名缺列,补列 SQL 由 `telemetry_schema_sql()` 自取)。
### 破坏性(请先读)
- **`TelemetryRecorder.record_llm_call` 新增必填 keyword `stream_end`**: 自建 recorder 未跟进会在装配期被形状闸拒绝(`**fields` 形态不受影响)。
### 提醒
- 保真模式交付 `finish_reason=length` 的工具调用时 arguments 可能是半截 JSON: **执行工具前先查 `finish_reason`**。
- 未知字段合并冲突时会**删除该字段**并按(源, 路径)告警一次: 结果可能缺字段,以告警为准。典型形态: 带 `index` 的数组(如 `reasoning_details`)后续帧元素缺 `index` 即判形态改变,**整个字段被删**。
- 诚实边界: 中转在上游出错后主动正常关闭连接,与真完整在传输层不可区分;防线是"finish 必须存在、不属上游错误、且其后有实测 usage"。
- 旧版本写入的 salvage/length 文本缓存无法辨认: 受影响集合须在升级后首次读写前更换 namespace/salt(同 1.3.9 提醒)。
## 1.3.9(2026-10-01)
- 新增 `chat(response_mode="openai")`,返回 `LLMResponse.completion`:assistant 消息、工具调用、finish_reason、上游 response_id、usage 快照;支持流式聚合及非流式。
- 修复 SSE 顶层 error 后接 DONE 被当成功、同帧 content/reasoning_content 丢失;工具实际进展纳入看门狗、首 token 与中段对冲。
- 保真仅支持单 choice,拒绝 structured 组合;工具参数逐字拼接,不执行或修复工具;缺 DONE 不允许 salvage。
- 保真缓存独立身份并完整回放;缺协议、畸形协议及无正文无工具的坏条目均按 miss 回源。**旧文本缓存可能含曾被吞错的半截结果,受影响集合须在升级首次读写前更换 namespace/salt(含 per-call 覆盖)**,库不会自动辨认旧污染。
- 新模式不绕过 managed/raw 冲突校验;raw-only 仍须源级/请求级均无受管意图。`Transport.complete` 新增必填 `response_mode`,自定义实现需适配。
- 分发采用 Apache-2.0;README 补充常驻事件循环线程桥接,不新增同步 API。
## 1.3.8(2026-09-14)
流式中段沉默的计量与应对(issue #27 / #28):`generation_ms` 口径重定义为**净生成时间**(扣除所有超阈值中段沉默)并配 `stall_ms`/`stall_count` 归因字段与遥测两列;另加一条**默认关闭**的中段沉默对冲(主路首 token 之后相邻增量 chunk 间隔超阈值时,向异源并发再发一次)。两键均不设时行为逐字等于 1.3.7(默认关闭回归门: 既有 unit/contracts 全套件语义断言一行未改)。
### 行为变更: `generation_ms` 口径重定义(1.3.7 墙钟 → 本版净生成时间)
> [!WARNING]
> **同一字段,两版口径不同。** 1.3.7 的 `generation_ms` 是赢家那次 transport 调用的完整**墙钟**(中段沉默全部计入);本版起是**净生成时间** = 墙钟 − Σ(所有 > `{SCOPE}__STREAM__STALL_THRESHOLD_MS` 的相邻增量 chunk 沉默),**仍含 TTFT 与尾部**(末 chunk → 流结束归活跃侧,墙钟基底口径的有意选择)。还原公式: **墙钟 = `generation_ms + stall_ms`**(`stall_ms` 为 None 时墙钟 = `generation_ms`)。
语义突变在一般情形下违反「字段只增不删不改名」精神,本次由人类以「窗口期最小代价」裁决放行(2026-09-11),依据三行: 该字段 1.3.7(2026-09-10)才引入、**发布仅 2 天**;**消费者唯一**(提出本需求的下游 Terminal-Bench,三参考项目均未消费);**遥测 DB 从未存过** `generation_ms`,无历史账本纵向断裂。字段名「裸生成时间」原本承诺的就是净口径,本次重定义是兑现名字而非推翻承诺。`max_inter_token_ms`(单次最大相邻间隔)原语义一字不动——它与 `generation_ms` 是两个口径,不能互算。
| 项 | 口径 |
| --- | --- |
| 计入沉默的间隔 | 相邻**增量 chunk**(`content` 或 `reasoning_content` 均计,与看门狗/首 token 活性口径一致)间隔**严格大于**阈值(恰好等于不减);空 chunk / usage 帧 / SSE keepalive 不计时;chunk 边界是传输/解析缓冲产物而非 token 边界,本口径不假装是 ITL |
| `stall_ms: int \| None` | 赢家路超阈值中段沉默总额(毫秒);与 `stall_count` **成对报告**(只报净数无法行动) |
| `stall_count: int \| None` | 超阈值相邻间隔个数 |
| None 条款(照 vLLM 排除条款) | 非流式 / 增量 chunk <2 → 两字段 None 且 `generation_ms` **不退减**(= 原墙钟);None = 「无沉默观测」而非 0,下游预算公式以 `stall_ms is None` 为分支条件 |
| 对冲赢家 | 三值全部取**赢家路**;已完成但落选的对冲输家其沉默在其 attempt 遥测行(可按 `logical_call_id` join 还原);被取消的输家无 TransportResult、`hedge_cancelled` 行 stall 恒 None |
| 与业界基准口径的区隔 | NVIDIA TTFT/ITL/E2EL 与 vLLM 的基准口径**均不扣除**沉默——扣沉默是本库自造口径,与基准数字比较时不得混用 |
### 遥测两列(纯增量,可空)
遥测表 `llm_calls` 末尾追加 `stall_ms` / `stall_count` 两**可空**列(INSERT 字段 36 → 38,物理列 37 → 39),落库值与 `CallStats` 快照同源(retry 层已取整的 int)。旧行 NULL 不回填(NULL 表达「补列之前根本没记过这件事」);auto 档幂等补列(先探测后 ALTER),manual 档按现有列裁剪写入并点名缺列,补列 SQL 由 `telemetry_schema_sql()` 自取。`generation_ms` **仍不进遥测列**——要墙钟用 `latency_ms` 或 `generation_ms + stall_ms` 还原。`TelemetryRecorder.record_llm_call` 协议同步加两个**无默认值** keyword-only 参数: 自建 recorder 未跟进会在**装配期**被形状闸当场拒绝(不是运行期静默丢行),`**fields` 形态不受影响;`emit_cache_hit` / `emit_terminal_failure` 与失败/取消 attempt 行两列恒 NULL。
### 中段沉默对冲(默认关闭;无业界公开先例)
新键 `{SCOPE}__HEDGE__STALL_AFTER_S`: 主路**首 token 之后**相邻增量 chunk 间隔超此阈值 → 向异源并发再发一路,先回者赢、输家取消。编排/记账/遥测全部复用 1.3.7 对冲语义(异源排除、完整准入、拿不到配额静默放弃、`hedge_cancelled` 遥测行、两败只计一次重试预算)。**诚实标注: 业界对中段沉默的落地形态收敛为「看门狗掐流 → 重试」,「中段沉默触发并发副本」在任何主流网关/库中均无公开先例**——本能力属超业界现状能力,启用前请理解其语义。
**请先读这三句**:
| # | 承诺 | 展开 |
| --- | --- | --- |
| 1 | **默认关闭,开启即用配额换延迟** | 不设该键时行为逐字等于 1.3.7。下游实测(Terminal-Bench 4407 次成功调用)沉默 >20s 占 **19.0%**——触发率远高于首 token 对冲(ttft >20s 仅 3/4407),开启后约两成调用会在触发窗口内 in-flight 翻倍 |
| 2 | **输家可能已被上游计费,取消止不住** | 与 1.3.7 同一性质: 输家按 est 保留预扣(闸内保守记账,不是上游真实计费的计量);取消止得住等待,止不住上游已经烧掉的钱。对账靠 `hedge_cancelled` 遥测行按 `logical_call_id` join |
| 3 | **独立于 `HEDGE__AFTER_S` 生效,仅流式 chat** | 两阈值各自独立,可独设可组合;仅设 `STALL_AFTER_S` 时首 token 前的沉默**不触发**(窗在首 token 到达后才开);仅设它且请求非流式 → 逐字走单路(中段沉默无非流式概念,等价未配置);Embedding/OCR scope 配了任一对冲键 → 装配期 warning 后静默退化 |
**与看门狗的交互及不对称声明(运维「挂起率」统计须知)**: 主路沉默超 `INTER_TOKEN_TIMEOUT_S` 被看门狗掐死 → 照常进 Transient 重试链并**喂熔断失败计数**(1.3.6 起既有语义),对冲路已在跑时本轮不因主路之死而败(等副路裁定,主路 attempt 记账照常);对冲输家被取消 → **不喂**熔断(1.3.7 已批准语义)。同为源沉默事件、两种处置,本版不改任一。
### 配置面
| 键 | 值域 | 缺省 | 守卫 |
| --- | --- | --- | --- |
| `{SCOPE}__STREAM__STALL_THRESHOLD_MS` | 正整数毫秒 | **1000** | 仅改计量口径,不改任何行为;调整接口三层同权(env 键 / `GatewaySettings.stall_threshold_ms` / `GatewayClient(stall_threshold_ms=...)`),无 per-call 覆盖,不做自适应(防与对冲阈值耦出反馈环) |
| `{SCOPE}__HEDGE__STALL_AFTER_S` | 有限正数秒 | **未设 = 关闭** | 装配守卫: `≥ min over sources of (inter_token_timeout_s or timeout_s)`(有效最小值,对冲窗永不开)→ **ValueError**;`≥ call_deadline_s` → ValueError;单源 scope 设阈值 → warning 允许 |
`GatewayClient(...)` 直传 `hedge_stall_after_s` / `stall_threshold_ms` 两 keyword-only 参数走同一份装配守卫;`from_settings`/`from_env` 照常透传。
## 1.3.7(2026-09-10)
给 chat 链路加了一条**默认关闭**的长尾对冲(issue #24):一次尝试挂起超过阈值时,并发向**另一个等价源**再发一次,先回者赢、输家取消。另给 `CallStats` 追加三个字段,其中 `generation_ms`(裸生成时间)对四条链路全部生效,与是否开启对冲无关。
### 关于对冲,请先读这三句
| # | 承诺 | 展开 |
| --- | --- | --- |
| 1 | **默认关闭,开启即用配额换延迟** | 不配 `{SCOPE}__HEDGE__AFTER_S` 时行为逐字等于 1.3.6(默认关闭回归门: 既有 unit/contracts 全套件一行断言未改)。开启后触发窗口内 in-flight 翻倍——两路各自走完整准入(配额闸/熔断门/pacer),拿不到配额**静默放弃**、原请求继续等,饱和期不添乱 |
| 2 | **输家可能已被上游计费,取消止不住** | 输家落既有取消路径按 est **保留预扣**(闸内保守记账,不是上游真实计费的计量);取消能止住等待,止不住上游已经烧掉的钱。对账靠遥测: 输家 attempt 行 `error="hedge_cancelled"`,与赢家行共享同一 `logical_call_id` 可 join |
| 3 | **对冲只对 chat 生效** | `EmbeddingClient`/`OcrClient` 不加对冲参数;它们本版的收益是 `CallStats.generation_ms` 计时(见下) |
### 触发语义
| 调用形态 | 触发判据 |
| --- | --- |
| 流式 | 已过 `hedge_after_s` **且首 token 未至**——慢生成不会被误对冲 |
| 非流式 | 已过 `hedge_after_s`,纯总时长阈值。非流式无中途信号,「挂起 vs 慢生成」物理不可分,只能靠阈值取值控制误对冲(建议取源 p50 的数倍) |
输家取消**不喂熔断/健康分**(挂起 ≠ 源死亡);两路都失败才进既有重试循环且**只计一次重试预算**(对冲是一次尝试的加速形态,不是两次独立尝试;两路皆 429 时按既有规则免预算并退 stall 账,一路 429 一路真失败则计一次不退还)。
### CallStats 三新字段(全带默认值,1.3.6 的构造方式不炸)
| 字段 | 口径 |
| --- | --- |
| `generation_ms: int = 0` | 裸生成时间: 赢家/成功那次 transport 调用的墙钟,**排除** admission 排队、重试退避、对冲触发前等待与清理遥测;与 `total_latency_ms` 的差值即「在等不在生成」的波动开销。结构化重问取最后一轮(覆盖),embedding 为各批 transport 之和(累加),缓存命中恒 0(未产生 transport 调用,0 是实测) |
| `hedges: int = 0` | 实际并发发出的对冲路数(触发但准入失败静默不计);未启用对冲恒 0 |
| `hedge_won: bool = False` | 赢家是否为对冲路;无对冲恒 False |
### 与 `call_deadline_s` 的关系
两者正交、可独立配置: deadline 让长尾**更早失败**,hedge 让调用**更快成功**。组合时装配守卫强制 `hedge_after_s < call_deadline_s`(违反即 `ValueError`);`hedge_after_s ≥ min(源 timeout_s)` 同样装配期报错(对冲永不可能触发);`hedge_after_s ≥ min(已设 ttft_timeout_s)` 与单源 scope 设阈值降为装配期 **warning**(流式档已被看门狗先行切断 / 运行期自然静默)。per-call `chat(call_deadline_s=X)` 使 X < 对冲阈值时该次调用对冲不触发,属合法语义不告警。
### 配置面
| 键 | 值域 | 缺省 |
| --- | --- | --- |
| `{SCOPE}__HEDGE__AFTER_S` | 有限正数秒(复用期限同款值域校验) | 未设 = 关闭 |
| `{SCOPE}__HEDGE__MAX_EXTRA` | int ∈ [1,3] | 1;**v1 仅单路对冲生效**,>1 接受但装配期 warning(梯次追加为预留) |
`GatewayClient(...)` 直传两 keyword-only 参数(`hedge_after_s` / `hedge_max_extra`)走同一份装配守卫;`from_settings`/`from_env` 照常透传。embedding/OCR 的 settings 嵌 `GatewaySettings` 故守卫照常跑,但对冲键对这两条链路不生效。
## 1.3.6(2026-09-10)
给一次逻辑调用加了一条**可选**墙钟硬边界(issue #22),并修好取消路径的 TPM 结算与 `Retry-After` 非有限值防御。
### 关于调用期限,请先读这三句
| # | 承诺 | 展开 |
| --- | --- | --- |
| 1 | **期限治理的是「等待」,不是「返回时刻」** | 到期后取消在飞的尝试,但清理(遥测写入、限流结算、缓存收尾)仍在 `finally` 里跑完,**允许超出期限**。实测构造(两次慢遥测写入)里返回时刻达期限的 **5–7 倍**;库只对「等待被切断」给承诺,对「多久返回」不给上界 |
| 2 | **到期 ≠ 未产出、≠ 未计费** | 上游可能已经算完并计费,只是结果在返回路上被丢弃(缓存写入慢于期限就是一例)。把 `CallDeadlineExceeded` 当成「这次没花钱」会低估成本 |
| 3 | **不配置就是 1.3.5 语义,逐字不变** | `call_deadline_s` 缺省 `None` 时根本不进 `asyncio.timeout` 上下文。故 1.3.5 的两条长等仍在: 纯 429 序列(429 不消耗重试预算)仍可能长时间等待;**有限大的 `Retry-After`(如 3600s)仍照睡**——库有意不用 `backoff_max_s` 去夹它,唯一制约手段就是本版这条期限 |
### 公共面新增(三项,全为纯新增)
| # | 位置 | 内容 |
| --- | --- | --- |
| 1 | `polygateway.CallDeadlineExceeded` | 新异常;带 `scope` / `deadline_s`,**无 `retry_after_s`**(到期不含「何时可再试」,给 `0.0` 会指示下游立刻重打饱和渠道) |
| 2 | 配置键 `{SCOPE}__CALL_DEADLINE_S` | 缺省不设 = 不启用;非法值(0/负/`nan`/`inf`/非数)在**装配期**当场 `ValueError` |
| 3 | 三个 client 构造参数 + 四个公开方法的 keyword-only 参数 | `GatewayClient` / `EmbeddingClient` / `OcrClient` 的 `call_deadline_s`;`chat` / `embed` / `recognize_text` / `parse_layout` 可 per-call 覆盖(`None` = 继承装配值,**不提供「本次关闭」**)。一次 `embed` 的 N 个批次共享同一份期限,不随批数放大 |
> [!WARNING]
> **`except GatewayUnavailableError` 接不住 `CallDeadlineExceeded`。** 新异常直接继承 `PolyGatewayError`,既不属四分类,也不在 `GatewayUnavailableError` 族内——期限到期是**调用方自己设的边界**,不是网关不可用。只有显式配了期限的调用方才会遇到它,需要处理就单列一条 `except`。遥测侧无需改动: 三个边界既有的 `except PolyGatewayError` 会接住它并照常写一条 `terminal_failure` 行(`error_type='CallDeadlineExceeded'`),**零新增列**。
### 行为变更:取消路径的 TPM 结算口径
| 情形 | 1.3.5 | 本版 |
| --- | --- | --- |
| 取消发生在**端口已开始、结算尚未确定**时 | `settle(0)`,入场预扣整笔退还 | 按 `est` **保留预扣**(方向是宁多扣不空退: 上游可能已计费) |
| 结算已确定(含真实 usage 恰为 0 的成功、已判 `SourceDead` 的 `0`) | 按已算出的值 | **一字不变**,取消不覆写 |
| 未被四分类接住的异常逃逸(`RuntimeError` 等) | `0` | **仍按 `0`**,本版不扩大语义(已登记为残留) |
启用期限后库自身会常规性触发取消路径,故这条记账修复与期限同版交付。OCR 的 `settle(0)` 不变——无 token 是事实而非「未知」。非取消路径的最终结算值与 1.3.5 逐字相同,只是算得更早(失败分支的结算决定前移到其第一个 `await` 之前)。
### 其他
- `Retry-After: inf` / `1e999`(`float()` 会把它舍成 `inf`)此前会原样进入退避并让该次尝试睡到天荒地老;现按「无提示」处理,退回纯指数退避,并发**一条**带源名与判据词的 warning(不回显原始头,429 风暴下会淹掉真信号)。`nan`、空串、负数、HTTP-date 的既有值语义一字未动。
- 限流 Lua、`Permit` 端口签名、遥测 schema、缓存 key 公式、重试预算与退避算法、熔断语义**均未改动**。
## 1.3.5(2026-09-09) ## 1.3.5(2026-09-09)
把治理单位从「一次尝试」补齐到「一次逻辑调用」(issue #19、#23)。此前重试、换源、结构化重问、embedding 分批都各自独立可见,而「这一次调用总共打了几次、总共花了多久、最后为什么失败」在库外拼不出来;结构化耗尽、embedding/OCR 的无源与准入拒绝更是**一条遥测行都没有**。 把治理单位从「一次尝试」补齐到「一次逻辑调用」(issue #19、#23)。此前重试、换源、结构化重问、embedding 分批都各自独立可见,而「这一次调用总共打了几次、总共花了多久、最后为什么失败」在库外拼不出来;结构化耗尽、embedding/OCR 的无源与准入拒绝更是**一条遥测行都没有**。
+16 -6
View File
@@ -2,10 +2,12 @@
> [!URGENT] > [!URGENT]
> **实验室内部通用基础库(生产级、非 MVP、零业务假设)** > **实验室内部通用基础库(生产级、非 MVP、零业务假设)**
>
> 1. 本项目是被多个科研/生产项目依赖的**库**,不是应用:稳定性、并发性、防御性、可观测与测试不可为"简单"让步(YAGNI 仍适用,但不削减健壮性)。库的 bug 会同时击穿所有下游项目。 > 1. 本项目是被多个科研/生产项目依赖的**库**,不是应用:稳定性、并发性、防御性、可观测与测试不可为"简单"让步(YAGNI 仍适用,但不削减健壮性)。库的 bug 会同时击穿所有下游项目。
> 2. 你的所有思考过程和回复必须使用 **简体中文**。 > 2. 你的所有思考过程和回复必须使用 **简体中文**。
## 1. 项目元数据 ## 1. 项目元数据
- **核心目标**: PolyGateway = 统一的大语言模型(LLM/VLM/OCR,音频预留)调度与中转库。治理单位是**一次模型调用**:请求封装、多源多账号、限流、错误分类与重试、熔断、Redis 响应缓存、流式看门狗、遥测(含成本)、结构化输出策略。全组件端口化可插拔。 - **核心目标**: PolyGateway = 统一的大语言模型(LLM/VLM/OCR,音频预留)调度与中转库。治理单位是**一次模型调用**:请求封装、多源多账号、限流、错误分类与重试、熔断、Redis 响应缓存、流式看门狗、遥测(含成本)、结构化输出策略。全组件端口化可插拔。
- **架构权威文档**: `research-wiki/ARCHITECTURE.md`(架构单一事实源,含 D1-D14 决策及讨论过程、子系统设计、三项目迁移验收标准;**不受 400 行设计文档限制**,以无歧义传达既有讨论为准绳)。开发顺序见 `research-wiki/ROADMAP.md`;`research-wiki/designs/` 仅存放每次实现具体功能的设计文档。 - **架构权威文档**: `research-wiki/ARCHITECTURE.md`(架构单一事实源,含 D1-D14 决策及讨论过程、子系统设计、三项目迁移验收标准;**不受 400 行设计文档限制**,以无歧义传达既有讨论为准绳)。开发顺序见 `research-wiki/ROADMAP.md`;`research-wiki/designs/` 仅存放每次实现具体功能的设计文档。
- **参考项目**: `reference/` 下三个项目是本库的需求来源与代码蓝本(**只读,勿改**;M4 起"只读"指工作区文件与 main 检出不变——迁移实施经 `git worktree` 在 `~/Projects/m4-worktrees/` 的 feature 分支进行,worktree 的 git 操作会写 `reference/*/.git` 元数据,属预期);库必须能按 ARCHITECTURE.md §11 被它们迁移接入,否则即边界缺口。 - **参考项目**: `reference/` 下三个项目是本库的需求来源与代码蓝本(**只读,勿改**;M4 起"只读"指工作区文件与 main 检出不变——迁移实施经 `git worktree` 在 `~/Projects/m4-worktrees/` 的 feature 分支进行,worktree 的 git 操作会写 `reference/*/.git` 元数据,属预期);库必须能按 ARCHITECTURE.md §11 被它们迁移接入,否则即边界缺口。
@@ -35,11 +37,13 @@ make ci # 只读验证(check + test)
> **同一理由适用于长跑命令**: 需要后台跑时(如全套件测试),命令末尾**不得接管道**,否则退出码失真;要判完成用 `wait`/轮询 PID,不要用会匹配到自身的 `pgrep -f "<完整命令串>"`。 > **同一理由适用于长跑命令**: 需要后台跑时(如全套件测试),命令末尾**不得接管道**,否则退出码失真;要判完成用 `wait`/轮询 PID,不要用会匹配到自身的 `pgrep -f "<完整命令串>"`。
### Phase 1: 规划与设计 ### Phase 1: 规划与设计
1. 涉及**公共 API、端口签名、架构边界、新子系统**的变更**必须**调用 `brainstorming`(产出 2-3 备选方案+权衡)并经**人类确认**后实施;其余任务自判(判据: 是否改变库对下游的承诺)。动手前查阅 `research-wiki/`(单一事实源)。 1. 涉及**公共 API、端口签名、架构边界、新子系统**的变更**必须**调用 `brainstorming`(产出 2-3 备选方案+权衡)并经**人类确认**后实施;其余任务自判(判据: 是否改变库对下游的承诺)。动手前查阅 `research-wiki/`(单一事实源)。
2. 功能产生运行时数据时**必须**调用 `structured-logging`。 2. 功能产生运行时数据时**必须**调用 `structured-logging`。
3. **里程碑级/跨多文件**功能编码前**必须**调用 `writing-plans`;小改动自判。审核门控: design 走 Claude 自审 → Codex 审 → **人类审**;plan 走 Claude 自审 → Codex 审 → 直接执行。 3. **里程碑级/跨多文件**功能编码前**必须**调用 `writing-plans`;小改动自判。审核门控: design 走 Claude 自审 → Codex 审 → **人类审**;plan 走 Claude 自审 → Codex 审 → 直接执行。
### Phase 2: 执行与验证 ### Phase 2: 执行与验证
1. **测试结果门**: 合并前每个行为变更必须有"先失败后通过"的测试证据(`test-driven-development`);bug 修复必带回归测试;不规定中间怎么走。 1. **测试结果门**: 合并前每个行为变更必须有"先失败后通过"的测试证据(`test-driven-development`);bug 修复必带回归测试;不规定中间怎么走。
2. **独立验证**: 里程碑级/跨多文件/合并前**必须**派全新上下文的 verifier subagent(`verification-before-completion`);任何规模的完成声明都必须逐条对应本会话内的工具输出(证据化声明,禁止虚报)。 2. **独立验证**: 里程碑级/跨多文件/合并前**必须**派全新上下文的 verifier subagent(`verification-before-completion`);任何规模的完成声明都必须逐条对应本会话内的工具输出(证据化声明,禁止虚报)。
3. **反 gold-plating**: 不做任务外的重构、抽象与"顺手清理"。 3. **反 gold-plating**: 不做任务外的重构、抽象与"顺手清理"。
@@ -47,6 +51,7 @@ make ci # 只读验证(check + test)
## 4. 核心规则 ## 4. 核心规则
### 4.1 核心原则(按优先级) ### 4.1 核心原则(按优先级)
- **P1 YAGNI**: 不写当前用不到的代码;但并发控制、防御校验、可观测埋点、错误隔离与测试是"当前需要",不在削减之列。 - **P1 YAGNI**: 不写当前用不到的代码;但并发控制、防御校验、可观测埋点、错误隔离与测试是"当前需要",不在削减之列。
- **P2 高可读性**: 领域术语命名;注释解释"为什么"。 - **P2 高可读性**: 领域术语命名;注释解释"为什么"。
- **P3 单一职责**: 一句话说不清职责(需要"和")= 拆分。 - **P3 单一职责**: 一句话说不清职责(需要"和")= 拆分。
@@ -58,7 +63,7 @@ make ci # 只读验证(check + test)
### 4.2 库铁律(本项目特有,违反即 bug) ### 4.2 库铁律(本项目特有,违反即 bug)
| 铁律 | 内容 | | 铁律 | 内容 |
|---|---| | --- | --- |
| 零业务假设 | 库内禁止出现任何下游业务领域词汇(视频/文书/超声等)与业务 fixtures;扩展点一律 Protocol | | 零业务假设 | 库内禁止出现任何下游业务领域词汇(视频/文书/超声等)与业务 fixtures;扩展点一律 Protocol |
| 纯 asyncio 中立 | 无全局状态、无框架假设、无模块级单例;同一 `GatewayClient` 在 arq worker 与裸脚本中行为一致 | | 纯 asyncio 中立 | 无全局状态、无框架假设、无模块级单例;同一 `GatewayClient` 在 arq worker 与裸脚本中行为一致 |
| 取消可穿透 | `asyncio.CancelledError` 永不捕获吞没;重试循环、限流等待、流式读取全部可被取消;in-flight 资源在 finally 释放 | | 取消可穿透 | `asyncio.CancelledError` 永不捕获吞没;重试循环、限流等待、流式读取全部可被取消;in-flight 资源在 finally 释放 |
@@ -69,6 +74,7 @@ make ci # 只读验证(check + test)
| 无缓存毒化 | 缓存 key 必含 model + messages 摘要 + 命名空间/租户 + salt;多模态 content 先摘要再 hash | | 无缓存毒化 | 缓存 key 必含 model + messages 摘要 + 命名空间/租户 + salt;多模态 content 先摘要再 hash |
### 4.3 代码开发规范 ### 4.3 代码开发规范
- 模块/类/方法必须有**中文 Docstring**;复杂逻辑用 `# Phase N` 注释组织。 - 模块/类/方法必须有**中文 Docstring**;复杂逻辑用 `# Phase N` 注释组织。
- 校验分层: 外部输入校验用显式异常(Python `-O` 移除 assert,禁止 assert 承担生产校验);assert 仅用于内部不变量。 - 校验分层: 外部输入校验用显式异常(Python `-O` 移除 assert,禁止 assert 承担生产校验);assert 仅用于内部不变量。
- 类名 `PascalCase`,私有前缀 `_`;导入顺序标准库→第三方→项目内。 - 类名 `PascalCase`,私有前缀 `_`;导入顺序标准库→第三方→项目内。
@@ -76,6 +82,7 @@ make ci # 只读验证(check + test)
- 不考虑向后兼容,直接修改原文件。**例外**: `LLMResponse` 等已被三项目消费的公共类型,字段只增不删不改名(迁移兼容约束,见 ARCHITECTURE.md §5.1)。 - 不考虑向后兼容,直接修改原文件。**例外**: `LLMResponse` 等已被三项目消费的公共类型,字段只增不删不改名(迁移兼容约束,见 ARCHITECTURE.md §5.1)。
### 4.4 Git 工作流 ### 4.4 Git 工作流
- 一切开发在 feature 分支,严禁直改 main;频繁语义化提交;提交**必须**调用 `commit` skill;大改动前先提交回滚点。 - 一切开发在 feature 分支,严禁直改 main;频繁语义化提交;提交**必须**调用 `commit` skill;大改动前先提交回滚点。
### 4.4.1 发布流程(每步都是历史欠账换来的,不得跳步) ### 4.4.1 发布流程(每步都是历史欠账换来的,不得跳步)
@@ -87,11 +94,11 @@ make ci # 只读验证(check + test)
按顺序执行,**构建之前**必须先改完所有文档: 按顺序执行,**构建之前**必须先改完所有文档:
| # | 动作 | 要点 | | # | 动作 | 要点 |
|---|---|---| | --- | --- | --- |
| 1 | **更新 README** | 打包会把当时的 README 固化进 sdist,**发布后再改就来不及了**(包里那份永远是旧的)。逐项核对: 安装命令的版本约束(`==1.1.*` 这类**极易漏改**,漏了下游就被锁在旧版)、能力表是否覆盖新行为、数字型断言是否仍成立(如遥测字段数,须用 `inspect.signature` 实测而非凭记忆) | | 1 | **更新 README** | 打包会把当时的 README 固化进 sdist,**发布后再改就来不及了**(包里那份永远是旧的)。逐项核对: 安装命令的版本约束(`==1.1.*` 这类**极易漏改**,漏了下游就被锁在旧版)、能力表是否覆盖新行为、数字型断言是否仍成立(如遥测字段数,须用 `inspect.signature` 实测而非凭记忆) |
| 2 | CHANGELOG 定版 | "未发布" → `## X.Y.Z(日期)` | | 2 | CHANGELOG 定版 | "未发布" → `## X.Y.Z(日期)` |
| 3 | 版本号 | `pyproject.toml` + `src/polygateway/__init__.py` 两处必须一致 | | 3 | 版本号 | `pyproject.toml` + `src/polygateway/__init__.py` 两处必须一致 |
| 4 | 合并 main + push | `--no-ff`;合并后在 main 上重跑 `make lint` 与全套件,**外加 `pytest -m slow`** ——真实网关 e2e 与 Redis 时间语义变体被 `addopts = "-m 'not slow'"` 默认排除,**不显式跑就等于没跑**(约 20-40 分钟,取决于网关快慢)。它们不进日常提交是有意的: pre-commit 关卡跑全套件,网关一抖就挡住与之无关的提交,久了会把"测试红了先怀疑网关"变成惯性,真 bug 也会被当成抖动重试掉;代价是这道门必须由本清单兜住 | | 4 | 合并 main + push | `--no-ff`;合并后在 main 上重跑 `make lint` 与全套件,**外加与本次 diff 有交集的 slow 子集**(2026-09-10 起): `pytest -m slow` 只跑被本版改动触及的模块——Redis 时间语义变体在动限流/退避/取消时跑,真实网关冒烟在动公开入口时跑;**`test_thinking_live.py` 全模型能力矩阵不再每次发布都跑**(付费且额度耗尽渠道会走完整超时链,一晚数小时无信号),仅在动 `thinking.py`/能力注册表/相关 e2e 设施或人类明确要求时跑,否则复用最近一次有效矩阵证据并如实登记。slow 不进日常提交是有意的: pre-commit 关卡跑全套件,网关一抖就挡住与之无关的提交,久了会把"测试红了先怀疑网关"变成惯性,真 bug 也会被当成抖动重试掉;代价是这道门必须由本清单兜住 |
| 5 | **打 tag 并 push** | `git tag -a vX.Y.Z -m "..."` + `git push origin vX.Y.Z`。历史上多个版本漏打 | | 5 | **打 tag 并 push** | `git tag -a vX.Y.Z -m "..."` + `git push origin vX.Y.Z`。历史上多个版本漏打 |
| 6 | 构建 | `rm -rf dist && python -m build && python -m twine check dist/*` | | 6 | 构建 | `rm -rf dist && python -m build && python -m twine check dist/*` |
| 7 | **上传 registry** | 凭据在 `~/.config/tea/config.yml`(tea CLI 的 Gitea token,**不在** `~/.pypirc`);token 走 `TWINE_PASSWORD` 环境变量,不进命令行<br>`TWINE_USERNAME=iomgaa TWINE_PASSWORD=$TOKEN python -m twine upload --repository-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi dist/*` | | 7 | **上传 registry** | 凭据在 `~/.config/tea/config.yml`(tea CLI 的 Gitea token,**不在** `~/.pypirc`);token 走 `TWINE_PASSWORD` 环境变量,不进命令行<br>`TWINE_USERNAME=iomgaa TWINE_PASSWORD=$TOKEN python -m twine upload --repository-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi dist/*` |
@@ -106,16 +113,18 @@ Gitea 包 registry 是 **owner 级**(`/iomgaa/-/packages/`)不是仓库级;PyPI
发布验证补充(2026-09-09 实测):私有索引不镜像 setuptools,下载 sdist 时 pip 即使带 `--no-deps` 仍可能尝试安装构建依赖;已具备构建工具的 conda 环境可加 `--no-build-isolation`,仍须核对下载来源与散列。包关联 POST 本次返回400,但匿名包页面和认证 GET 包 API 均证实已关联目标仓库;**400本身不算成功,也不能直接推断已关联**,必须读取实际 `repository.full_name` 与页面链接。包 API 可能要求认证(匿名401),页面仍应匿名亲查;不要为重试关联先解绑现有正确关系。 发布验证补充(2026-09-09 实测):私有索引不镜像 setuptools,下载 sdist 时 pip 即使带 `--no-deps` 仍可能尝试安装构建依赖;已具备构建工具的 conda 环境可加 `--no-build-isolation`,仍须核对下载来源与散列。包关联 POST 本次返回400,但匿名包页面和认证 GET 包 API 均证实已关联目标仓库;**400本身不算成功,也不能直接推断已关联**,必须读取实际 `repository.full_name` 与页面链接。包 API 可能要求认证(匿名401),页面仍应匿名亲查;不要为重试关联先解绑现有正确关系。
### 4.5 配置管理 ### 4.5 配置管理
- 工程配置走 `pydantic-settings` + `.env`(模板 `.env.example`,敏感项不提交);严禁硬编码默认值;缺失关键配置直接报错。 - 工程配置走 `pydantic-settings` + `.env`(模板 `.env.example`,敏感项不提交);严禁硬编码默认值;缺失关键配置直接报错。
- 多源命名约定 `{SCOPE}__{PROVIDER}__{N}__{FIELD}`;韧性参数键名沿用三项目习惯(`LLM_TIMEOUT` 等),降低迁移成本。 - 多源命名约定 `{SCOPE}__{PROVIDER}__{N}__{FIELD}`;韧性参数键名沿用三项目习惯(`LLM_TIMEOUT` 等),降低迁移成本。
- 装配只有两条路: `GatewayClient.from_env()`/`from_settings()`(工厂)或构造函数全量注入(测试/高级);库内部任何组件不得自读环境变量。 - 装配只有两条路: `GatewayClient.from_env()`/`from_settings()`(工厂)或构造函数全量注入(测试/高级);库内部任何组件不得自读环境变量。
### 4.6 测试组织 ### 4.6 测试组织
- `tests/{unit,integration,e2e}`;真实场景优先(录制的真实网关响应二次构造优于凭空 mock)。 - `tests/{unit,integration,e2e}`;真实场景优先(录制的真实网关响应二次构造优于凭空 mock)。
- 覆盖率目标 80%;并发/韧性行为是一等测试对象: 重试穿透取消、熔断开路半开、限流结算退款、Redis 掉线降级方向、缓存 key 隔离。 - 覆盖率目标 80%;并发/韧性行为是一等测试对象: 重试穿透取消、熔断开路半开、限流结算退款、Redis 掉线降级方向、缓存 key 隔离。
- Redis 相关测试用真实 Redis(integration),不 mock Lua 行为;限流契约测试随实现一起交付(参考 CHSAnalyzer `tests/contracts_limiter.py`)。 - Redis 相关测试用真实 Redis(integration),不 mock Lua 行为;限流契约测试随实现一起交付(参考 CHSAnalyzer `tests/contracts_limiter.py`)。
- 涉及真实 LLM 的测试输出结构化 Markdown 至 `tests/outputs/<module>/<test>_<ts>.md`。 - 涉及真实 LLM 的测试输出结构化 Markdown 至 `tests/outputs/<module>/<test>_<ts>.md`。
- **成败取决于外部服务当下状态的测试一律标 `slow`**(`tests/e2e/` 四个文件与 Redis 时间语义变体):它们默认不进日常套件,由发布清单第 4 步统一跑。判据是"重跑一次可能就绿了"——这种测试留在提交关卡里会污染信号。同理,给它们的超时不得紧于 `.env` 的生产配置,否则是设计上就会间歇红。 - **成败取决于外部服务当下状态的测试一律标 `slow`**(`tests/e2e/` 四个文件与 Redis 时间语义变体):它们默认不进日常套件,由发布清单第 4 步**按 diff 交集选子集**跑(全量 `pytest -m slow` 仅在交集不清或人类要求时用)。判据是"重跑一次可能就绿了"——这种测试留在提交关卡里会污染信号。同理,给它们的超时不得紧于 `.env` 的生产配置,否则是设计上就会间歇红。
## 5. 项目结构 ## 5. 项目结构
@@ -140,7 +149,7 @@ project_root/
## 6. 上下文导航 ## 6. 上下文导航
| 需求 | 路径 | | 需求 | 路径 |
|---|---| | --- | --- |
| 架构全貌: 决策 D1-D14 及讨论过程、端口清单、错误分类、子系统设计、迁移验收 | `research-wiki/ARCHITECTURE.md`(单一事实源) | | 架构全貌: 决策 D1-D14 及讨论过程、端口清单、错误分类、子系统设计、迁移验收 | `research-wiki/ARCHITECTURE.md`(单一事实源) |
| 开发顺序与里程碑状态 | `research-wiki/ROADMAP.md`(活文档,随进度更新) | | 开发顺序与里程碑状态 | `research-wiki/ROADMAP.md`(活文档,随进度更新) |
| 三项目迁移文档(ARCHITECTURE §11 的展开,库设计的常驻约束) | `research-wiki/migrations/`(govdoc-saas / video-tree-trm5 / chsanalyzer) | | 三项目迁移文档(ARCHITECTURE §11 的展开,库设计的常驻约束) | `research-wiki/migrations/`(govdoc-saas / video-tree-trm5 / chsanalyzer) |
@@ -154,6 +163,7 @@ project_root/
| 第一次抽库尝试(教训与蓝本) | `reference/GovDoc-SaaS/packages/docagent-core/` | | 第一次抽库尝试(教训与蓝本) | `reference/GovDoc-SaaS/packages/docagent-core/` |
## 7. 输出规范 ## 7. 输出规范
- 所有输出**中文**;文档优先表格/伪代码/Mermaid;禁止超 15 行代码块入文档、禁止连续超 5 条碎片列点;设计 ≤400 行、计划 ≤1000 行、报告 ≤300 行。 - 所有输出**中文**;文档优先表格/伪代码/Mermaid;禁止超 15 行代码块入文档、禁止连续超 5 条碎片列点;设计 ≤400 行、计划 ≤1000 行、报告 ≤300 行。
- **例外**: `research-wiki/ARCHITECTURE.md` 不受行数限制——它的准绳是"后来的 AI/人类无需还原原始讨论即可准确理解全部决策及理由",宁详勿略(详细 ≠ 琐碎:记录论证与取舍,不堆砌实现细节)。 - **例外**: `research-wiki/ARCHITECTURE.md` 不受行数限制——它的准绳是"后来的 AI/人类无需还原原始讨论即可准确理解全部决策及理由",宁详勿略(详细 ≠ 琐碎:记录论证与取舍,不堆砌实现细节)。
@@ -163,7 +173,7 @@ project_root/
> **Skill 的 description 即触发边界。** 下表标注 **MANDATORY** 的情形是硬门(设计人类门、合并前测试证据门、合并前独立验证门、commit 格式),不得跳过;其余情形按 description 边界自判——自判看任务实质(规模/风险/是否触及公共承诺),不是省事。任何 skill 流程不得引入任务外的重构或抽象。优先级: 用户显式指令 > Skill 详细流程 > 本文件宏观规则。 > **Skill 的 description 即触发边界。** 下表标注 **MANDATORY** 的情形是硬门(设计人类门、合并前测试证据门、合并前独立验证门、commit 格式),不得跳过;其余情形按 description 边界自判——自判看任务实质(规模/风险/是否触及公共承诺),不是省事。任何 skill 流程不得引入任务外的重构或抽象。优先级: 用户显式指令 > Skill 详细流程 > 本文件宏观规则。
| Skill | 触发边界 | | Skill | 触发边界 |
|-------|---------| | ------- | --------- |
| `brainstorming` | **MANDATORY**: 公共 API/端口签名/架构边界/新子系统变更;其余自判 | | `brainstorming` | **MANDATORY**: 公共 API/端口签名/架构边界/新子系统变更;其余自判 |
| `writing-plans` | **MANDATORY**: 里程碑级/跨多文件功能;小改动自判 | | `writing-plans` | **MANDATORY**: 里程碑级/跨多文件功能;小改动自判 |
| `test-driven-development`(测试结果门) | **MANDATORY**: 合并前——行为变更须有先失败后通过的测试证据 | | `test-driven-development`(测试结果门) | **MANDATORY**: 合并前——行为变更须有先失败后通过的测试证据 |
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+223 -8
View File
@@ -16,17 +16,22 @@
| 熔断 | 双通道(连续失败 + 失败率窗口,健康证据抑制误熔);半开单探针带租约(持有者死亡自动回收);epoch fencing 拒绝迟到写回;开路时长指数递增;**开路时当场失败还是等冷却可配**(`CIRCUIT_OPEN`,单源 scope 应配 `wait`) | | 熔断 | 双通道(连续失败 + 失败率窗口,健康证据抑制误熔);半开单探针带租约(持有者死亡自动回收);epoch fencing 拒绝迟到写回;开路时长指数递增;**开路时当场失败还是等冷却可配**(`CIRCUIT_OPEN`,单源 scope 应配 `wait`) |
| 自适应并发 | AIMD:429 削减、成功缓升,防止打爆上游 | | 自适应并发 | AIMD:429 削减、成功缓升,防止打爆上游 |
| 背压与判死 | 配额满与熔断开路**各自**可选等待或快速失败(`QUOTA_FULL` / `CIRCUIT_OPEN`,两键不可互相替代);等待期按双条件判死(本地非生产性等待与全局无进展**同时**超窗)。stall 窗口只计**非生产性**等待(429 退避/配额轮询/熔断冷却),与 `TIMEOUT_S` 无耦合 | | 背压与判死 | 配额满与熔断开路**各自**可选等待或快速失败(`QUOTA_FULL` / `CIRCUIT_OPEN`,两键不可互相替代);等待期按双条件判死(本地非生产性等待与全局无进展**同时**超窗)。stall 窗口只计**非生产性**等待(429 退避/配额轮询/熔断冷却),与 `TIMEOUT_S` 无耦合 |
| 调用期限 | 一次逻辑调用可选一条**墙钟硬边界**(`{SCOPE}__CALL_DEADLINE_S` 或 `chat(call_deadline_s=...)`,缺省不启用):治理的是**等待**——重试退避、配额轮询、熔断冷却、结构化重问与 embedding 分批共享同一份期限。三条须知:①**返回时刻 = 期限 + 清理耗时**(遥测/结算/缓存收尾在 `finally` 里跑完,允许超期;实测构造达期限的 5–7 倍),库只承诺切断等待、不给返回上界;②**到期 ≠ 未产出、≠ 未计费**,上游可能已算完并计费;③**不配置即逐字保持 1.3.5 语义**(纯 429 序列仍可能长等、有限大 `Retry-After` 仍照睡)。到期抛 `CallDeadlineExceeded`,**不属四分类、不属 `GatewayUnavailableError` 族** |
| 长尾对冲 | **默认关闭**的可选加速(`{SCOPE}__HEDGE__AFTER_S`,仅 chat):一次尝试挂起超阈值时向**异源**并发再发一次,先回者赢、输家取消。流式以「首 token 未至」为触发判据(不误杀慢生成),非流式只有纯总时长阈值;对冲走完整限流/熔断准入,拿不到配额静默放弃。成本须知:开启即用配额换延迟(触发窗口内 in-flight 翻倍),输家取消按 est 保留预扣且**可能已被上游计费**(取消止不住上游);输家不喂熔断,遥测 attempt 行标 `error="hedge_cancelled"` 可按 `logical_call_id` join 对账;两路皆败只计一次重试预算;与 `call_deadline_s` 正交组合(装配守卫强制对冲阈值 < 期限)
| 中段沉默对冲(可选,超业界现状能力) | **默认关闭**(`{SCOPE}__HEDGE__STALL_AFTER_S`,仅流式 chat):主路**首 token 之后**相邻增量 chunk 沉默超阈值时向异源并发再发一次,与首 token 对冲 `HEDGE__AFTER_S` **独立生效**、可独设可组合;仅设它且请求非流式 → 逐字走单路(中段沉默无非流式概念)。**诚实标注:中段沉默触发并发副本在主流网关/库中无公开先例**(业界收敛为看门狗掐流重试)。成本须知:下游实测沉默 >20s 占 19.0%,触发率远高于首 token 对冲,开启即用配额换延迟,输家可能已被上游计费;看门狗掐死**喂**熔断而对冲输家取消**不喂**(不对称声明),详见[1.3.8 生成时间口径与中段沉默对冲](#138-生成时间口径与中段沉默对冲) |
| OpenAI 消息保真(1.3.9) | `chat(response_mode="openai")` 返回完整 assistant 消息/工具调用、finish_reason、上游 ID 与 usage;单 choice,复用治理,不执行工具;默认仍为文本模式 |
| 完成判定(1.3.10) | 以上游 `finish_reason` 为准判定"生成结束、结果可交付":缺 `[DONE]` 但 finish 与其后的实测 usage 齐全即完整(两模式);length/过滤等确定性结局不再被当瞬时错误重试;未知/`null` 字段不再判失败;`LLMResponse.finish_reason` / `stream_end` 给出结束原因与方式,详见[1.3.10 统一完成判定](#1310-统一完成判定) |
| 响应缓存 | Redis/内存;key 含 model + messages 摘要 + namespace(缓存隔离单位)+ salt + 采样参数 + 请求级推理档位(同 messages 跑 low 与 max 不互相命中),多模态 content 先摘要再 hash(防毒化);可 per-call 绕过(科研重采样) | | 响应缓存 | Redis/内存;key 含 model + messages 摘要 + namespace(缓存隔离单位)+ salt + 采样参数 + 请求级推理档位(同 messages 跑 low 与 max 不互相命中),多模态 content 先摘要再 hash(防毒化);可 per-call 绕过(科研重采样) |
| 流式看门狗 | TTFT / inter-token / 总超时三层活性;thinking token 刷活性不计结果;截断流(缺 `[DONE]`)判瞬时不入缓存 | | 流式看门狗 | TTFT / inter-token / 总超时三层活性;thinking token(含 `reasoning` 别名)刷活性不计结果;截断流(无 `[DONE]` 且无 finish+其后实测 usage)默认判瞬时(`MISSING_DONE=salvage` 的文本打捞结果交付但不入缓存) |
| 推理可观测性 | "这次到底推理没推理"由多信号裁定(推理正文压倒 usage 明细),三态落在 `LLMResponse.thinking_observation`:`observed` / `absent` / `unknown`——**`unknown` 是"本次判不出",不是"没推理"**;本次实发档位与实测观测矛盾时按 `(源, 模型, 生效档位)` 各告警一次(能力表过期、开启未生效、注入了却观测不到;同一模型的 low 与 max 是两个独立的矛盾,不共用节流键);裁定结果随遥测落库 | | 推理可观测性 | "这次到底推理没推理"由多信号裁定(推理正文压倒 usage 明细),三态落在 `LLMResponse.thinking_observation`:`observed` / `absent` / `unknown`——**`unknown` 是"本次判不出",不是"没推理"**;本次实发档位与实测观测矛盾时按 `(源, 模型, 生效档位)` 各告警一次(能力表过期、开启未生效、注入了却观测不到;同一模型的 low 与 max 是两个独立的矛盾,不共用节流键);裁定结果随遥测落库 |
| 推理档位 | 推理是**八档**(`none`/`auto`/`minimal`/`low`/`medium`/`high`/`xhigh`/`max`)而非开关:源级 `REASONING_EFFORT` + 请求级 `chat(reasoning_effort=...)`,`ENABLE_THINKING` 保留为语法糖;库带 24 条能力表(逐条 evidence 自报实测/文档推定),档位打空**默认报错并给出该模型最省的可用档与该配的键**,要静默映射需显式配 `EFFORT_FALLBACK=nearest`;实发档随 `LLMResponse.applied_effort` 与遥测落库 | | 推理档位 | 推理是**八档**(`none`/`auto`/`minimal`/`low`/`medium`/`high`/`xhigh`/`max`)而非开关:源级 `REASONING_EFFORT` + 请求级 `chat(reasoning_effort=...)`,`ENABLE_THINKING` 保留为语法糖;库带 24 条能力表(逐条 evidence 自报实测/文档推定),档位打空**默认报错并给出该模型最省的可用档与该配的键**,要静默映射需显式配 `EFFORT_FALLBACK=nearest`;实发档随 `LLMResponse.applied_effort` 与遥测落库 |
| 遥测与成本 | 每次调用(含缓存命中与失败)必录 36 字段;三类行(`event_kind` = `attempt` / `cache_hit` / `terminal_failure`)加逐源诊断列(`http_status_code` / `error_type` / `cause_type` / `error_body`);SQLite / Postgres 后端(表已存在时**不需要** schema 建表权限,最小权限账号可直接用);按价格表折算成本落库(注意 `LLMResponse.cost` 本身恒为 `None`,成本只进遥测);多模态内容摘要落库不存原图 | | 遥测与成本 | 每次调用(含缓存命中与失败)必录 39 字段;三类行(`event_kind` = `attempt` / `cache_hit` / `terminal_failure`)加逐源诊断列(`http_status_code` / `error_type` / `cause_type` / `error_body`);SQLite / Postgres 后端(表已存在时**不需要** schema 建表权限,最小权限账号可直接用);按价格表折算成本落库(注意 `LLMResponse.cost` 本身恒为 `None`,成本只进遥测);多模态内容摘要落库不存原图 |
| 逻辑调用统计 | 治理单位是**一次逻辑调用**而非一次尝试:四种响应(chat / embedding / OCR 两种)带 `call_stats`(`logical_call_id` / `attempts` / `total_latency_ms`),重试、换源、结构化重问、embedding 分批共享同一逻辑 ID;每次**领域失败**另落一条 `terminal_failure` 行,失败调用数从此是一条 `WHERE event_kind = 'terminal_failure'`,详见[1.3.5 逻辑调用统计与失败诊断](#135-逻辑调用统计与失败诊断) | | 逻辑调用统计 | 治理单位是**一次逻辑调用**而非一次尝试:五种响应(chat / embedding / OCR 三种)带 `call_stats`(`logical_call_id` / `attempts` / `total_latency_ms`,及生成时间 `generation_ms`——**1.3.8 起为净口径**:赢家那次 transport 调用墙钟 − Σ超阈值中段沉默,仍含 TTFT 与尾部,排除准入排队/退避/触发前等待;1.3.7 的墙钟值由 `generation_ms + stall_ms` 还原——与沉默归因 `stall_ms` / `stall_count`(成对报告,None = 无沉默观测)、对冲计数 `hedges` / `hedge_won`),重试、换源、结构化重问、embedding 分批共享同一逻辑 ID;每次**领域失败**另落一条 `terminal_failure` 行,失败调用数从此是一条 `WHERE event_kind = 'terminal_failure'`,详见[1.3.5 逻辑调用统计与失败诊断](#135-逻辑调用统计与失败诊断)与[1.3.8 生成时间口径与中段沉默对冲](#138-生成时间口径与中段沉默对冲) |
| 遥测的资源与降级 | Postgres 池**闲时占 0 条连接**、忙时上限可配(`PGW_TELEMETRY_PG_POOL_MAX`,缺省 4),每次写入有硬预算(`PGW_TELEMETRY_PG_WRITE_TIMEOUT_S`,缺省 5s);后端不可用是**可恢复的降级**(冷却 60s 后自动重试,DBA 建完表/放开权限即自愈),永久失能只留给 DSN 本身写错;降级状态可编程查询——`client.telemetry_status` 给出 `degraded`/`fatal`/`reason`/`dropped_rows` 等只读快照,不必再靠人工对账。**对账要同时看 `degraded` 与 `dropped_rows`**: 池饱和超预算丢的行走行级丢弃,`degraded` 保持 `False`(后端没挂,是本进程并发超了),只按 `degraded` 告警会看不见这一类丢行——而它恰是 `pool_max` 配小了的唯一信号 | | 遥测的资源与降级 | Postgres 池**闲时占 0 条连接**、忙时上限可配(`PGW_TELEMETRY_PG_POOL_MAX`,缺省 4),每次写入有硬预算(`PGW_TELEMETRY_PG_WRITE_TIMEOUT_S`,缺省 5s);后端不可用是**可恢复的降级**(冷却 60s 后自动重试,DBA 建完表/放开权限即自愈),永久失能只留给 DSN 本身写错;降级状态可编程查询——`client.telemetry_status` 给出 `degraded`/`fatal`/`reason`/`dropped_rows` 等只读快照,不必再靠人工对账。**对账要同时看 `degraded` 与 `dropped_rows`**: 池饱和超预算丢的行走行级丢弃,`degraded` 保持 `False`(后端没挂,是本进程并发超了),只按 `degraded` 告警会看不见这一类丢行——而它恰是 `pool_max` 配小了的唯一信号 |
| 调用方维度 | 每次调用可带 `tenant_id`(遥测表的真实列,可挂 RLS、可建复合索引)与 `meta`(≤16 个自定义 KV);四个公共方法全覆盖,校验超限即报错;**库只交付列,不启用 RLS、不建索引** | | 调用方维度 | 每次调用可带 `tenant_id`(遥测表的真实列,可挂 RLS、可建复合索引)与 `meta`(≤16 个自定义 KV);五个公共方法全覆盖,校验超限即报错;**库只交付列,不启用 RLS、不建索引** |
| 遥测表治理 | `llm_calls` 是**下游的表**:PG 侧缺省**不再自动 `ALTER` 补列**(`PGW_TELEMETRY_SCHEMA_MODE` 三态,不设则 sqlite→auto、postgres→manual),manual 档点名缺列并按现有列裁剪写入;`telemetry_schema_sql(backend)` 自取可粘进迁移文件的建表/补列 SQL;`PGW_TELEMETRY_TEXT_CAP` 限正文长度(**不设 = 存全文**);保留期与访问控制走[生产部署 DDL 模板](#生产部署-ddl-模板postgresql)加 `tools/telemetry_retention.py` | | 遥测表治理 | `llm_calls` 是**下游的表**:PG 侧缺省**不再自动 `ALTER` 补列**(`PGW_TELEMETRY_SCHEMA_MODE` 三态,不设则 sqlite→auto、postgres→manual),manual 档点名缺列并按现有列裁剪写入;`telemetry_schema_sql(backend)` 自取可粘进迁移文件的建表/补列 SQL;`PGW_TELEMETRY_TEXT_CAP` 限正文长度(**不设 = 存全文**);保留期与访问控制走[生产部署 DDL 模板](#生产部署-ddl-模板postgresql)加 `tools/telemetry_retention.py` |
| 结构化输出 | json_repair 修复 / 原生 schema 双策略 + 校验失败有界带反馈重问 | | 结构化输出 | json_repair 修复 / 原生 schema 双策略 + 校验失败有界带反馈重问 |
| OCR | MonkeyOCR 双端点(文本转录 + 版面解析),bbox 数值防御下沉,逐源健康预检 `check_health()` | | OCR | MonkeyOCR 双端点(文本转录 + 版面解析)+ 整份文档解析 `parse_document`(1.3.11:PDF 按内容标注,逐块类型/页码/bbox/文字 + 整篇 Markdown,详见[1.3.11 OCR 整份文档解析](#1311-ocr-整份文档解析)),bbox 数值防御下沉,逐源健康预检 `check_health()` |
| Embedding | 分批、维度校验、与 chat 同一治理栈 | | Embedding | 分批、维度校验、与 chat 同一治理栈 |
**降级方向是铁律**:缓存/遥测后端掉线 → 降级而不冒泡(业务调用照常返回);限流/熔断后端掉线 → 报错而非放行(防击穿上游)。遥测的降级**不是静默的**——进入/恢复各一条日志、期间按行数与时间节流复述,并随时可经 `client.telemetry_status` 读到。`asyncio.CancelledError` 全链路穿透,in-flight 资源在 finally 释放;**资源所有权的纪律是「谁建的谁关」**——`aclose()` 只关自己 `from_env()`/`from_settings()` 建出来的组件,注入进来的 transport / recorder / limiter / breaker / cache 一律不碰(由注入方自己关)。 **降级方向是铁律**:缓存/遥测后端掉线 → 降级而不冒泡(业务调用照常返回);限流/熔断后端掉线 → 报错而非放行(防击穿上游)。遥测的降级**不是静默的**——进入/恢复各一条日志、期间按行数与时间节流复述,并随时可经 `client.telemetry_status` 读到。`asyncio.CancelledError` 全链路穿透,in-flight 资源在 finally 释放;**资源所有权的纪律是「谁建的谁关」**——`aclose()` 只关自己 `from_env()`/`from_settings()` 建出来的组件,注入进来的 transport / recorder / limiter / breaker / cache 一律不碰(由注入方自己关)。
@@ -109,13 +114,40 @@ stats.total_latency_ms # 含缓存 IO、退避、准入等待、重
自建 `TelemetryRecorder` 的下游请同步补齐这 10 个 keyword-only 无默认值参数(或改成 `**fields` 形态),并同步自己的 schema 与 INSERT 字段。装配期形状闸只能证明形状能被接受,证不了函数体真的落这些列。 自建 `TelemetryRecorder` 的下游请同步补齐这 10 个 keyword-only 无默认值参数(或改成 `**fields` 形态),并同步自己的 schema 与 INSERT 字段。装配期形状闸只能证明形状能被接受,证不了函数体真的落这些列。
## 1.3.8 生成时间口径与中段沉默对冲
> [!WARNING]
> **`generation_ms` 自 1.3.8 起是净生成时间,不再是墙钟。** 同一字段两版口径不同(放行依据:字段发布仅 2 天、消费者唯一、遥测无账本,人类「窗口期最小代价」裁决);1.3.7 的墙钟值 = 本版的 `generation_ms + stall_ms`。两段口径的存量快照不可直接纵比。
### `generation_ms` 口径表
| 项 | 口径 |
| --- | --- |
| 定义 | **净生成时间** = 赢家那次 transport 调用的墙钟 − Σ(所有 > `STALL_THRESHOLD_MS`(缺省 1000ms)的相邻增量 chunk 沉默);仍**排除**准入排队/退避/对冲触发前等待(同 1.3.7) |
| 边界 | 仍含 **TTFT 与尾部**(末 chunk → 流结束归活跃侧,墙钟基底口径的有意选择) |
| 还原公式 | **墙钟 = `generation_ms + stall_ms`**(`stall_ms is None` 时墙钟 = `generation_ms`) |
| `stall_ms` / `stall_count` | 沉默总额(毫秒)与超阈值相邻间隔个数,**成对报告**(只报净数无法行动);chunk = 任何增量产出(`content`、`reasoning_content`、工具实际增量,1.3.10 起另含未知字段的非空字符串片段如 `reasoning`),空 chunk / usage 帧 / SSE keepalive / 仅标签键(`index`/`type`/`id`/`format`)不计时 |
| `stall_ms is None` 分支条件 | 非流式 / 增量 chunk <2 → 两字段 None 且 `generation_ms` **不退减**;None = 「无沉默观测」而非 0,下游预算公式以此为分支条件 |
| 与基准口径的区隔 | NVIDIA TTFT/ITL/E2EL 与 vLLM 的基准口径**均不扣除**沉默——扣沉默是本库自造口径,与基准数字比较时不得混用 |
`max_inter_token_ms`(单次最大相邻间隔)原语义不变,与 `generation_ms` 是两个口径、不可互算。沉默归因同批进遥测表 `stall_ms`/`stall_count` 两**可空**列(INSERT 字段 36 → 38,旧行 NULL 不回填);`generation_ms` 本身仍不落库,墙钟用上式还原。对冲场景三值全部取**赢家路**:落选输家的沉默在其 attempt 遥测行(可按 `logical_call_id` join),被取消输家的 `hedge_cancelled` 行 stall 恒 NULL。
### 配置表(两键,均仅 chat 生效)
| 键 | 值域 | 缺省 | 守卫与调整接口 |
| --- | --- | --- | --- |
| `{SCOPE}__STREAM__STALL_THRESHOLD_MS` | 正整数毫秒 | **1000** | 仅改计量口径,不改任何行为;三层调整接口同权(env 键 / `GatewaySettings.stall_threshold_ms` / `GatewayClient(stall_threshold_ms=...)`);无 per-call 覆盖,不做自适应(防与对冲阈值耦出反馈环) |
| `{SCOPE}__HEDGE__STALL_AFTER_S` | 有限正数秒 | **未设 = 关闭** | 独立于 `HEDGE__AFTER_S` 生效;守卫: `≥ min(各源 inter_token_timeout_s 或 timeout_s)`(对冲窗永不开)或 `≥ call_deadline_s` → 装配期 **ValueError**,单源 scope → warning;三层调整接口同权(env 键 / `GatewaySettings.hedge_stall_after_s` / `GatewayClient(hedge_stall_after_s=...)`);Embedding/OCR scope 配任一对冲键 → warning 后静默退化 |
对冲语义(两阶段触发窗/复检防误触发/取消穿透)与成本披露见 [CHANGELOG 1.3.8 段](CHANGELOG.md);运维「挂起率」统计须知不对称:看门狗掐死**喂**熔断失败计数,对冲输家取消**不喂**熔断。
## 安装 ## 安装
发布在实验室 Gitea PyPI(公开包,匿名可装): 发布在实验室 Gitea PyPI(公开包,匿名可装):
```bash ```bash
pip install --extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ \ pip install --extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ \
"polygateway[redis,postgres,structured]>=1.3.5,<2" "polygateway[redis,postgres,structured]>=1.3.11,<2"
``` ```
核心仅依赖 `httpx` + `pydantic`;按需选 extras: 核心仅依赖 `httpx` + `pydantic`;按需选 extras:
@@ -176,6 +208,7 @@ from polygateway.ocr import OcrClient
ocr = OcrClient.from_env("OCR") # OCR__MONKEY__1__* 多源 ocr = OcrClient.from_env("OCR") # OCR__MONKEY__1__* 多源
text = await ocr.recognize_text(image_bytes) # 文本转录 text = await ocr.recognize_text(image_bytes) # 文本转录
layout = await ocr.parse_layout(image_bytes) # 版面解析(带 bbox 的元素列表) layout = await ocr.parse_layout(image_bytes) # 版面解析(带 bbox 的元素列表)
doc = await ocr.parse_document(pdf_bytes) # 整份文档(PDF 或图片):逐块文字 + 整篇 Markdown
health = await ocr.check_health() # 逐源预检 {"monkey_1": True, ...} health = await ocr.check_health() # 逐源预检 {"monkey_1": True, ...}
embed = EmbeddingClient.from_env("EMBED") # EMBED__*__* + EMBED__BATCH_SIZE embed = EmbeddingClient.from_env("EMBED") # EMBED__*__* + EMBED__BATCH_SIZE
@@ -185,13 +218,17 @@ vectors = (await embed.embed(["文本 a", "文本 b"])).vectors
### 4. 业务侧异常处理 ### 4. 业务侧异常处理
```python ```python
from polygateway import GatewayUnavailableError, RequestRejectedError from polygateway import CallDeadlineExceeded, GatewayUnavailableError, RequestRejectedError
try: try:
resp = await client.chat(messages) resp = await client.chat(messages)
except GatewayUnavailableError as exc: except GatewayUnavailableError as exc:
# 整个 scope 暂时无源可用: 延期重投,不消耗业务失败预算 # 整个 scope 暂时无源可用: 延期重投,不消耗业务失败预算
schedule_retry(after_s=exc.retry_after_s) # exc.reason / exc.per_source_reasons 供诊断 schedule_retry(after_s=exc.retry_after_s) # exc.reason / exc.per_source_reasons 供诊断
except CallDeadlineExceeded as exc:
# 只在自己配了调用期限时出现: 不在 GatewayUnavailableError 族内,上一条接不住;
# 且无 retry_after_s(到期不含"何时可再试"),重投时机由业务侧定
schedule_retry(after_s=None) # exc.scope / exc.deadline_s 供诊断
except RequestRejectedError: except RequestRejectedError:
... # 请求本身有问题(400/格式拒绝): 不重试,直接失败 ... # 请求本身有问题(400/格式拒绝): 不重试,直接失败
``` ```
@@ -206,7 +243,7 @@ resp = await client.chat(
) )
``` ```
**1.2.1 起**,四个公共方法(`chat` / `embed` / `recognize_text` / `parse_layout`)都接受这两个关键字参数,都可省略,既有调用点无需改动。校验在入口收口、**超限报 `ValueError` 而非静默丢弃**:`tenant_id` ≤128 字符、非空串、不含首尾空白(空白**拒绝而非 strip**——`" t1"` 与 `"t1"` 在 RLS 等值比较下是两个租户);`meta` 最多 16 个键,键须匹配 `[a-z0-9_.]{1,64}`(`pg_` 前缀保留给库),值仅限 `str` / `int` / `float` / `bool`,字符串值 ≤256 字符、`float` 须有限(`nan` / `inf` 不是合法 JSON,JSONB 会拒收)。两者**都不进缓存 key**——缓存隔离由 `cache_namespace` 负责。 **1.2.1 起**,公共方法(`chat` / `embed` / `recognize_text` / `parse_layout`,1.3.11 起含 `parse_document`)都接受这两个关键字参数,都可省略,既有调用点无需改动。校验在入口收口、**超限报 `ValueError` 而非静默丢弃**:`tenant_id` ≤128 字符、非空串、不含首尾空白(空白**拒绝而非 strip**——`" t1"` 与 `"t1"` 在 RLS 等值比较下是两个租户);`meta` 最多 16 个键,键须匹配 `[a-z0-9_.]{1,64}`(`pg_` 前缀保留给库),值仅限 `str` / `int` / `float` / `bool`,字符串值 ≤256 字符、`float` 须有限(`nan` / `inf` 不是合法 JSON,JSONB 会拒收)。两者**都不进缓存 key**——缓存隔离由 `cache_namespace` 负责。
存储上 `tenant_id` 两端都是 `TEXT NOT NULL DEFAULT ''`,`meta` 在 Postgres 是 `JSONB`、在 SQLite 是 `TEXT`;老表要补上这两列(补列是否由库自动执行取决于 `PGW_TELEMETRY_SCHEMA_MODE`,见[遥测表 schema 与升级纪律](#遥测表-schema-与升级纪律)),**补列后老行读出是空串而非 NULL**(NULL 在任何 RLS policy 下都对所有人不可见,空串则可用一条 SQL 审出还有多少行待归属)。 存储上 `tenant_id` 两端都是 `TEXT NOT NULL DEFAULT ''`,`meta` 在 Postgres 是 `JSONB`、在 SQLite 是 `TEXT`;老表要补上这两列(补列是否由库自动执行取决于 `PGW_TELEMETRY_SCHEMA_MODE`,见[遥测表 schema 与升级纪律](#遥测表-schema-与升级纪律)),**补列后老行读出是空串而非 NULL**(NULL 在任何 RLS policy 下都对所有人不可见,空串则可用一条 SQL 审出还有多少行待归属)。
@@ -463,6 +500,8 @@ SQLite 侧**不建议**对着一个大库文件跑 `DELETE` + `VACUUM`,而应**
**网关拒绝的理由不会丢失**(1.2.0 起):非 2xx 的响应体经折叠与截断后同时进入异常 message 与 `exc.body_text`,故遥测表的 `error` 列里就能看到网关的原话——不必再为查一次 400 单独埋点。截断保头保尾(总长 2048 字符),JSON 错误体尾部的 `code` / `request_id` 不会被切掉。**经中转部署时请注意**:第三方中转服务自身抖动也会回 400,从状态码上与"你的输入有问题"无法区分;库仍按确定性失败处理(直连供应商时重试只会白烧配额),批处理下游宜据 `body_text` 自备兜底分类。 **网关拒绝的理由不会丢失**(1.2.0 起):非 2xx 的响应体经折叠与截断后同时进入异常 message 与 `exc.body_text`,故遥测表的 `error` 列里就能看到网关的原话——不必再为查一次 400 单独埋点。截断保头保尾(总长 2048 字符),JSON 错误体尾部的 `code` / `request_id` 不会被切掉。**经中转部署时请注意**:第三方中转服务自身抖动也会回 400,从状态码上与"你的输入有问题"无法区分;库仍按确定性失败处理(直连供应商时重试只会白烧配额),批处理下游宜据 `body_text` 自备兜底分类。
**有限大的 `Retry-After` 仍照睡**: 能力表那条「尊重 `Retry-After`」是字面意思——库**有意不用 `backoff_max_s` 去夹服务端给的提示**(夹住就是提前重打已明确说「还没好」的网关),服务端给 3600s 就真睡 3600s;**唯一的制约手段是 1.3.6 的调用期限**(上表「调用期限」行)。自 1.3.6 起,`inf` / `-inf` / `1e999` 这类**非有限**取值按「无提示」处理(退回纯指数退避 + 一条带源名的 warning),不再造成无限等待;`nan`、空串、负数、HTTP-date 的既有语义不变。
### 哪些异常会到达调用方 ### 哪些异常会到达调用方
上表的"库内行为"一列描述的是**治理动作**,不是调用方要处理的东西。四类里有两类**根本到不了调用方**——它们被重试循环接住,预算耗尽时统一包成 `AllSourcesExhausted`。这个区分只看类型树和 docstring 是读不出来的,曾让下游据此写错整段设计文档,故在此列明: 上表的"库内行为"一列描述的是**治理动作**,不是调用方要处理的东西。四类里有两类**根本到不了调用方**——它们被重试循环接住,预算耗尽时统一包成 `AllSourcesExhausted`。这个区分只看类型树和 docstring 是读不出来的,曾让下游据此写错整段设计文档,故在此列明:
@@ -473,9 +512,12 @@ SQLite 侧**不建议**对着一个大库文件跑 `DELETE` + `VACUUM`,而应**
| `RequestRejectedError` | `SourceDeadError`(立即熔断该源并换源,同上) | | `RequestRejectedError` | `SourceDeadError`(立即熔断该源并换源,同上) |
| `ResultInvalidError` | | | `ResultInvalidError` | |
| `SourceNotConfiguredError` | | | `SourceNotConfiguredError` | |
| `CallDeadlineExceeded`(1.3.6 起) | |
**`GovernanceBackendError` 属于第一列**: 限流/熔断的状态后端(如 Redis)自身故障时库 fail-closed——一个请求都发不出去,这就是"整个 scope 暂时不可用"。它继承 `GatewayUnavailableError`,所以 §4 那段 `except GatewayUnavailableError` 一条即覆盖完整,无需为它单列分支。`retry_after_s` 默认 5 秒(后端恢复时间不可知,取 0 会让积压任务零延迟冲击已挂掉的后端)。 **`GovernanceBackendError` 属于第一列**: 限流/熔断的状态后端(如 Redis)自身故障时库 fail-closed——一个请求都发不出去,这就是"整个 scope 暂时不可用"。它继承 `GatewayUnavailableError`,所以 §4 那段 `except GatewayUnavailableError` 一条即覆盖完整,无需为它单列分支。`retry_after_s` 默认 5 秒(后端恢复时间不可知,取 0 会让积压任务零延迟冲击已挂掉的后端)。
**`CallDeadlineExceeded` 既不属四分类、也不属 `GatewayUnavailableError` 族**: 它直接继承 `PolyGatewayError`,表达的是「调用方自己设的墙钟边界到了」而非网关不可用,故**只有显式配了 `call_deadline_s` / `{SCOPE}__CALL_DEADLINE_S` 的调用方才可能遇到它**,不配就永远不会出现。它**无 `retry_after_s`**,且 §4 那段 `except GatewayUnavailableError` **接不住**它——要处理就得单列一条分支。
**`SourceNotConfiguredError` 有意不在第一列的族内**: 源名不在限流后端的配置字典中是**装配缺陷**而非暂时故障,它应当消耗失败预算、进死信、让人看见——归入可重投家族只会让配置写错的任务永远重投且无人告警。 **`SourceNotConfiguredError` 有意不在第一列的族内**: 源名不在限流后端的配置字典中是**装配缺陷**而非暂时故障,它应当消耗失败预算、进死信、让人看见——归入可重投家族只会让配置写错的任务永远重投且无人告警。
## 配置参考 ## 配置参考
@@ -571,3 +613,176 @@ make ci # 只读全量验证
## 兼容性承诺 ## 兼容性承诺
`LLMResponse` 等被下游消费的公共类型,字段**只增不删不改名**且新增字段必带默认值;`{SCOPE}__{PROVIDER}__{N}__{FIELD}` 与平铺韧性键名(`LLM_TIMEOUT` 等)沿用三项目既有习惯,不做破坏性改名。实验室内部库,随实验室项目需求演进。 `LLMResponse` 等被下游消费的公共类型,字段**只增不删不改名**且新增字段必带默认值;`{SCOPE}__{PROVIDER}__{N}__{FIELD}` 与平铺韧性键名(`LLM_TIMEOUT` 等)沿用三项目既有习惯,不做破坏性改名。实验室内部库,随实验室项目需求演进。
## 1.3.9 OpenAI 工具消息保真(单 choice)
`chat(..., response_mode="openai")` 仍返回 `LLMResponse`,完整协议结果在
`response.completion`:`message`、`finish_reason`、`response_id`、`usage`。
`content/thinking` 是旧文本投影(可剥离 think 标签);回传代理或继续对话应使用
completion.message,不能从旧投影重建工具响应。嵌套 JSON 是独立快照但仍可修改。
```python
response = await client.chat(
messages,
response_mode="openai",
overlay={"tools": tools, "tool_choice": "auto"},
)
completion = response.completion
messages.append(completion.message)
# 工具执行与 role=tool 结果由调用方添加;库不会执行工具。
```
保真是 JSON 字段和值保真,不是 HTTP 字节透明代理。仅支持单 choice(`n=1`),
可含多个并行 function tool_calls;arguments 逐字拼接,不解析、修复或执行。
SSE 的 role/content/reasoning_content/tool_calls 按原语义聚合;1.3.10 起 `null` 视为缺省,
未知增量字段按流式惯例无损合并(冲突删键并告警,见下节);
非流式保留整个 message 扩展字段,不返回顶层 logprobs/created 等其他字段。
上游实际模型仍读 `model_reported`,配置别名是 `model`。
| 边界 | 行为 |
| --- | --- |
| 空文本且有完整工具 | 正常成功;不完整工具失败(length 截断的半截工具归 ResultInvalid),其余见 1.3.10 结局表 |
| error 帧 | 所有模式立即判失败并走已有重试;后续 DONE 不挽救 |
| 缺 DONE | 1.3.10 起 finish + 其后实测 usage + 干净 EOF 即完整;真截断仍不受 salvage 放宽,防截断工具被执行 |
| finish_reason=length | 原样返回(含只有思考的回答),不修补参数;**执行工具前先查 finish_reason**,arguments 可能是半截 JSON |
| structured | 不可与保真并用;自行传 overlay.response_format 不启用库的修复/重问阶梯 |
| raw 推理 | 保真不是校验逃生口;raw-only 要求源 ENABLE_THINKING/REASONING_EFFORT 均未设置,请求档为 None,再通过 overlay.reasoning_effort 传原值 |
工具实际增量与正文/推理共用首 token、inter-token、stall 对冲及沉默计量;注释、
usage、role-only、重复工具身份不算进展。非流式无中段事件,保持纯时长阈值对冲。
`Transport.complete` 的自定义实现须接收必填 `response_mode`;不支持保真时明确
RequestRejected,不能伪造 completion 或静默退回文本。
缓存按返回模式隔离;缺协议、畸形协议或无正文无工具的条目按 miss 回源。命中保留历史上游 response_id/usage,生成本次治理 call_id,
本次 attempts=0、cost=0。**旧文本缓存可能含修复前 error+DONE 的截断产物**:
受影响调用集合须在首次读写前更换从未承载旧语义的 namespace 或 salt,包含工厂、
全量注入和 per-call 覆盖,保留租户前缀与 epoch。未迁移仍可能读旧值,库不自动清除。
遥测继续单一出口,不新增工具全文列;既有 TEXT_CAP 不覆盖 tool_calls.arguments。
### 线程模型的异步桥接
库不新增同步客户端。socketserver 等工作线程应向**一个常驻事件循环线程**提交:
client 必须在该 loop 内创建、使用并关闭,不能每请求 asyncio.run 或跨 loop 共用
httpx/Redis 组件。下例的 loop 和 client 已由宿主完成初始化:
```python
future = asyncio.run_coroutine_threadsafe(
client.chat(messages, response_mode="openai", overlay=overlay), loop
)
try:
response = future.result(timeout=timeout_s)
except TimeoutError:
future.cancel() # 断连也应取消;取消不等于上游未计费。
raise
```
应用退出时先停止接单、取消/等待在途调用,在同一个 loop 中 await client.aclose(),
再停 loop 并 join 线程;注入组件仍由所有者关闭。不要从 loop 本身阻塞调用 future.result。
## 1.3.10 统一完成判定
#30/#31 同根: 1.3.9 以 `[DONE]` 存在、正文非空、字段白名单作为成功判据,上游的正常结局
(不发 `[DONE]`、只有思考的 length 截断、`refusal: null`)被当瞬时错误重试到耗尽。1.3.10
改以上游 `finish_reason` 与传输完整性为准,流式/非流式/缓存四处共用一份判定。
| 结束方式(`stream_end`) | 条件 |
| --- | --- |
| `done` | 收到 `[DONE]` |
| `eof_after_finish` | 未发 `[DONE]`,但 finish 已到、其同帧或之后收到的 usage 为实测,连接干净关闭(用量记 measured) |
| `salvaged` | 文本模式 `MISSING_DONE=salvage` 打捞的截断结果(用量降 estimated,**不写缓存**) |
| `None` | 非流式 / 缓存命中 / 失败行 |
| 结局(首个匹配行生效) | `openai` 模式 | `text` 模式 |
| --- | --- | --- |
| finish 为 `network_error`/`error`(上游自报失败) | 瞬时错误 | 瞬时错误 |
| 工具快照不成立 | length → `ResultInvalidError`;其余瞬时 | — |
| 无任何产物 + length/过滤 | 原样交回 | `ResultInvalidError`(不重试、不计熔断) |
| 无任何产物 + 其余(零增量抖动) | 瞬时错误 `empty_completion` | 同左 |
| 有正文 | 交付 | 交付 |
| 只有思考/拒答/空白 + length/过滤 | 原样交回 | `ResultInvalidError` |
| 只有思考/拒答/空白 + stop 等 | 原样交回 | 瞬时错误(证据收集中) |
| 只有思考/拒答/空白 + 无 finish | 瞬时错误 | 同左 |
`ResultInvalidError` 不经 `AllSourcesExhausted` 包装,直接到调用方;原因码在异常消息里
(`length_without_content` / `filtered_without_content` / `tool_truncated` 等,遥测 `error`
列可检索)。只有自然结束(stop/tool_calls/function_call,或未上报 finish 的旧口径)且投影正文非空
(或工具完整)的结果才写缓存;未登记的 finish 值按正常交付、不缓存并告警一次。无参函数只发
`arguments: null` 的工具判 `ResultInvalidError`(`tool_arguments_null`,确定性方言,不改写不重试)。文本模式 `thinking` 在无非空 `reasoning_content` 时取
`reasoning`(vLLM/OpenRouter 新名)。保真模式未知字段:字符串拼接、对象递归、带 `index` 的
数组按 index 合并、`index/type/id/format` 同值幂等;冲突时**删除该字段**并按(源, 路径)
告警一次——结果可能缺字段,以告警为准。诚实边界:中转在上游出错后主动正常关闭连接与真
完整在传输层不可区分,防线是"finish 必须存在、不属上游错误、且其后有实测 usage"。
遥测 `llm_calls` 追加可空列 `stream_end`;`TelemetryRecorder.record_llm_call` 新增必填
keyword `stream_end`(**自建 recorder 须跟进,否则装配期被形状闸拒绝**)。
## 1.3.11 OCR 整份文档解析
#34: `OcrClient.parse_document(bytes)` 把 PDF(或图片)整份交给 MonkeyOCR,返回逐块的
类型 / 页码 / bbox / 文字与整篇 Markdown。治理链路(限流、熔断、重试、遥测、
`tenant_id` / `meta`、`call_deadline_s`)与 `parse_layout` 完全一致;`parse_layout` /
`recognize_text` 的签名与返回类型不变。
```python
doc = await ocr.parse_document(pdf_bytes) # 上传类型由库按内容判定,无需也无法声明
for el in doc.elements: # 服务阅读序;页内按页序
el.page_index, el.type, el.bbox, el.text
doc.page_sizes[el.page_index] # 与 bbox 同一原生坐标系,页码零基
doc.markdown # 服务生成的整篇 Markdown
```
| 字段 | 含义 |
| --- | --- |
| `elements[].text` | `text` / `title`: 纯文本(行内公式保留 `$…$`);`interline_equation`: 裸 LaTeX;`table`: HTML 表体;`image`: 空串 |
| `elements[].type` | 开放字符串(服务给什么就是什么)。嵌套的 caption / footnote 子块若出现,作为独立元素紧随父块输出 |
| `markdown` | 服务生成的整篇原文,**含悬空的 `![](images/<sha>.jpg)` 图片引用**——库不外传图片 |
### 行为变更(请先读)
| 变更 | 说明 |
| --- | --- |
| PDF 上传标签 | MonkeyOCR 按**上传文件名的扩展名**分派。此前所有上传都标 `image.jpg`;现在**前 1KB 内含 `%PDF-` 且起始不是已知图片魔数**的字节改标 `doc.pdf`(容忍垃圾前缀;副作用是前 1KB 里恰好出现 `%PDF-` 字样的非 PDF 文本也会被改标),**其余一律沿用 `image.jpg`**。三个方法共用这一个判定 |
| `recognize_text(PDF)` | **修复静默错误**: PDF 标成 `image.jpg` 时服务返回 `success:true` 与幻觉文本(实测 `"The image contains a single character: 1"`);现在返回整份全文(无页码,请改用 `parse_document`) |
| `parse_layout(PDF)` | 上传分支由图片数据集改为 PDF 数据集。已测的两种 2 页 PDF(文本型、无文字层的位图型)结果与此前**逐字相同**;**旋转页、带批注页未测**,不构成保证。医院线只传图片,不受影响 |
| 坏输入(仅 `parse_document`) | 无法识别的字节(HTML 页面、乱码、空串)本地拒绝、不发请求;服务对截断/伪造 PDF 返回的已知确定性 500(`Failed to open stream` 等)归 `RequestRejectedError`(`status_code=500`),**不重试、不计熔断**。其余 500 仍是瞬时错误 |
### 部署配方(文档线)
长文档与医院线的图片请求共用同一个 MonkeyOCR 服务时,建议文档线用**独立 scope**,自带超时 / 重试 / 熔断:
```bash
OCR_DOC__MONKEY__1__BASE_URL=http://10.77.0.20:7866
OCR_DOC__MONKEY__1__API_KEY=none
OCR_DOC__MONKEY__1__MODEL=monkey-ocr
OCR_DOC__MONKEY__1__TIMEOUT_S=600 # ≥ 最大页数 × 4s × 余量(实测 1.5–4 s/页;共用 GPU 的争用再乘 3–5 倍)
OCR_DOC__MONKEY__1__MAX_CONCURRENCY=2 # 服务为同步串行时取 1,见下
OCR_DOC__MONKEY__1__TRUST_ENV=false
OCR_DOC__RETRY__MAX_ATTEMPTS=2
OCR_DOC__CALL_DEADLINE_S=900 # 单份文档的硬上限;须 ≤ PGW_LEASE_TTL_S(缺省 1500)
```
### 运维须知
| # | 事项 |
| --- | --- |
| 1 | **`timeout_s` 不是总耗时上界**: 它是逐次网络操作的超时。POST 阶段服务处理期间无字节,读超时约等于处理时长上限;GET 下载只要持续有分块到达,总时长可超过任意多个 `timeout_s`。**硬上限只来自 `call_deadline_s`**。permit 租约与半开探针租约到期后会被回收 / 接管,所以文档线应让 `call_deadline_s ≤ lease_ttl_s` |
| 2 | 库内**没有任何基于延迟的熔断 / 慢判定**,长文档不会因为"慢"被判故障;唯一把慢变成失败的是 httpx 超时(瞬时错误,计熔断) |
| 3 | **重试放大**: POST 成功(服务已算完)而 GET 失败时,重试会重新 POST 整份文档重算;超时重试同理,且服务端是否因断连取消计算无证据——请把 `RETRY__MAX_ATTEMPTS` 压低 |
| 4 | **无页数预检**: 库不依赖 PDF 解析库,发送前不知页数。数百页的文档会占满超时被重投,页数过滤是调用方的职责 |
| 5 | **服务端磁盘**: 每次调用在服务 `/app/tmp` 与 `/static` 留下产物(几百篇约几百 MB 到 GB 级),库不管理,请告知服务运维 |
| 6 | **多 worker 共享并发上限**需要 `PGW_LIMITER_BACKEND=redis` 且各进程同 scope;默认 memory 后端的 `MAX_CONCURRENCY` 是进程内的 |
| 7 | **服务端并发模型**: 若 MonkeyOCR 部署为 sync-only 后端(出厂默认 `lmdeploy`),`/parse` 在每个实例上**全局串行**——"并发 2"不提速,`timeout_s` 须覆盖"排队 + 处理",且一份 15 页文档(按实测 1.5–4 s/页估算,约 22–60 秒)的队头阻塞会拖慢同实例上医院线的请求。请向运维确认 backend(服务启动日志有一行 `async-capable` / `sync-only`),sync-only 时文档线每实例并发 1、错峰运行 |
| 8 | 源 `timeout_s` 与 permit 租约的装配守卫只比较 1×(`timeout_s ≤ lease_ttl_s`);跨 scope 的守卫改造另行立项,不在本版 |
### 已知限制
- **服务端丢弃脚注、页眉页脚、页码**(版面模型把它们标为丢弃类,span 内容为空,未被 OCR):论文表格脚注里的显著性与缩写说明不会出现在 `elements` 或 `markdown` 里,库无从补偿。
- 多行 / 多 span 的文本块、`inline_equation` span、嵌套 caption / footnote 子块这三条路径**没有真实样本**(实测样本全是 1 行 1 span,默认版面模型不产生嵌套子块),只由构造 fixture 测试。
- **内存**: 文档路径整包上限 256 MiB(流式累计),合并分块会让瞬时峰值接近 2 倍上限;**字节上限不等于内存上限**——JSON 解析后的对象体积可达原文数十倍(实测 11 MiB 的空数组 JSON 解析后约 270 MiB),异常大的包请按此预留内存。真实样本约 72 KB/页,正常大小是毫秒级;`_middle.json` 的 64 MiB 上限与整包 256 MiB 上限都只界定字节数,不界定成员条数。
- `parse_document` 仅接受 PDF 与魔数表内的图片(JPEG / PNG / TIFF / BMP / GIF / WebP);表外的可解码图片格式请走 `parse_layout`。
- `parse_layout` 的校验口径保持原样(bbox 整数化退化拒绝、`page_idx` 非法回退枚举下标、无体积上限);`parse_document` 更严的有零页拒绝与 `page_idx` 必须等于页序,更宽的是 bbox 只要求有限且有序。
## 许可证
PolyGateway 使用 [Apache License 2.0](LICENSE)。内部移植代码的原创 owner 已明确授权;
分发范围不包含 `reference/` 外部参考仓。
+3 -1
View File
@@ -4,11 +4,12 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "polygateway" name = "polygateway"
version = "1.3.5" version = "1.3.11"
description = "PolyGateway:实验室统一的大语言模型(LLM/VLM/OCR)调度与中转库——多源、限流、重试、熔断、缓存、遥测" description = "PolyGateway:实验室统一的大语言模型(LLM/VLM/OCR)调度与中转库——多源、限流、重试、熔断、缓存、遥测"
# registry 包页面的正文只认这一项:缺了页面就是一片空白(1.1.2 的教训,twine 会警告 # registry 包页面的正文只认这一项:缺了页面就是一片空白(1.1.2 的教训,twine 会警告
# long_description missing 但不阻塞上传)。README 在打包时被固化进产物,发布后再改无效。 # long_description missing 但不阻塞上传)。README 在打包时被固化进产物,发布后再改无效。
readme = "README.md" readme = "README.md"
license = {file = "LICENSE"}
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"httpx>=0.27", "httpx>=0.27",
@@ -82,6 +83,7 @@ layers = [
"polygateway.middleware", "polygateway.middleware",
"polygateway.transports | polygateway.backends | polygateway.telemetry | polygateway.structured", "polygateway.transports | polygateway.backends | polygateway.telemetry | polygateway.structured",
"polygateway.thinking", "polygateway.thinking",
"polygateway.deadline",
"polygateway.providers : polygateway.sources", "polygateway.providers : polygateway.sources",
"polygateway.ports : polygateway.types : polygateway.errors : polygateway.streaming", "polygateway.ports : polygateway.types : polygateway.errors : polygateway.streaming",
] ]
+50 -6
View File
@@ -210,6 +210,8 @@ HTTP API → arq 队列 → worker 协程 脚本 → asyncio.gather 协
**背景(调研结论)**: Video-Tree 用 `/ocr/text` 把帧文字作为"硬证据"并置进 VLM 提示词,**裸调**(无重试/限流/熔断,单帧失败跳过,手写双端点轮询)——迁移后免费升级为全治理。CHSAnalyzer 用 `/parse` 定位表格 bbox,已全治理。GovDoc 无 OCR 且是明确架构否决(旧系统被"OCR 挂死"拖垮,教训反哺 §6.4 取消语义)。多后端预留有直接证据:CHSAnalyzer provider 白名单已含 `glm`(延后),设计文档明确三种后端图片输入形态各异(VLM=base64、Monkey=multipart、GLM=URL)——因此端口收 `bytes`,编码差异封装在 invoker 内部。 **背景(调研结论)**: Video-Tree 用 `/ocr/text` 把帧文字作为"硬证据"并置进 VLM 提示词,**裸调**(无重试/限流/熔断,单帧失败跳过,手写双端点轮询)——迁移后免费升级为全治理。CHSAnalyzer 用 `/parse` 定位表格 bbox,已全治理。GovDoc 无 OCR 且是明确架构否决(旧系统被"OCR 挂死"拖垮,教训反哺 §6.4 取消语义)。多后端预留有直接证据:CHSAnalyzer provider 白名单已含 `glm`(延后),设计文档明确三种后端图片输入形态各异(VLM=base64、Monkey=multipart、GLM=URL)——因此端口收 `bytes`,编码差异封装在 invoker 内部。
**1.3.11 增补(#34,整份文档解析)**: 端口族增第三成员 `OcrDocumentPort.parse_document(bytes)`(PDF 或图片整份 → 逐块 `OcrDocumentElement` + `page_sizes` + 整篇 `markdown`)。根因与取舍见 `designs/2026-10-21-issue34-ocr-document-design.md`: M3 的"服务按内容处理"只在 35 张图片样本上验证过,而 MonkeyOCR **按上传文件名扩展名分派**(PDF 标 `image.jpg` 时 `/ocr/text` 返回 `success:true` 的幻觉文本);库把输入建模成"一张图 + 写死的标签"、输出建模成"只有框",没有"文档"这个概念。决策要点: ① 新 Protocol `OcrDocumentTransport` **独立于** `OcrTransport`(旧 `runtime_checkable` Protocol 不加成员);② 上传类型由库按内容判定(图片魔数优先 → 前 1KB 内 `%PDF-`),不提供调用方声明参数;③ 内容只取自 `_middle.json` + 配对 `.md`,不读 `_content_list.json`(展示格式,转义 `* ` ~ $`);④ 确定性坏输入的 500 只在新路径窄化为"请求被拒",共享分类不变(#35)。
**影响**: 协议细节与后处理分界见 §7.10;`TableLocator` 这类业务化端口留在业务侧,由库端口结果组装。 **影响**: 协议细节与后处理分界见 §7.10;`TableLocator` 这类业务化端口留在业务侧,由库端口结果组装。
### D10 音频只留端口 ### D10 音频只留端口
@@ -413,7 +415,7 @@ flowchart TB
| 值 | 含义 | 生产者 | cost | | 值 | 含义 | 生产者 | cost |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `measured` | usage 帧完整可信 | 正常路径;OCR 成功行(0 token 是**事实**而非未知) | 按 token 换算 | | `measured` | usage 帧完整可信 | 正常路径;OCR 成功行(0 token 是**事实**而非未知) | 按 token 换算 |
| `estimated` | 有实测数字但可信度降级 | 打捞路径(收到 usage 帧但流被截断,§7.1) | 按 token 换算 | | `estimated` | 有实测数字但可信度降级 | 打捞路径(收到 usage 帧但流被截断,§7.1;1.3.10 起即 `stream_end="salvaged"`) | 按 token 换算 |
| `unavailable` | 用量信息不可得 | usage 帧缺失、失败尝试、终态失败 | **NULL** | | `unavailable` | 用量信息不可得 | usage 帧缺失、失败尝试、终态失败 | **NULL** |
值域在 `types.py` 以模块级 frozenset 常量 `USAGE_SOURCES` 落地,**仅约束库内生产侧**(所有写入点从该常量取值),不在 `LLMResponse`/`Usage`/`TransportResult` 上加 `__post_init__` 值域校验——它们是运行时构造点,裸 `ValueError` 不属 §6 四分类、`RetryMW` 不捕会逃出 `chat()`;且 `LLMResponse` 是三项目已消费的公共类型,新增运行时校验属下游可见行为变更。历史库里既有的 `estimated` 行在新值域中依然合法可读。 值域在 `types.py` 以模块级 frozenset 常量 `USAGE_SOURCES` 落地,**仅约束库内生产侧**(所有写入点从该常量取值),不在 `LLMResponse`/`Usage`/`TransportResult` 上加 `__post_init__` 值域校验——它们是运行时构造点,裸 `ValueError` 不属 §6 四分类、`RetryMW` 不捕会逃出 `chat()`;且 `LLMResponse` 是三项目已消费的公共类型,新增运行时校验属下游可见行为变更。历史库里既有的 `estimated` 行在新值域中依然合法可读。
@@ -424,13 +426,13 @@ flowchart TB
### 5.2 其他类型与 `chat()` 公共签名 ### 5.2 其他类型与 `chat()` 公共签名
`ChatRequest`(model/messages/结构化输出参数/per-call 覆盖项)、`Usage`(tokens + elapsed,OCR 无计费填 0)、`OcrTextResult`(text + 溯源五件 source_name/usage/latency_ms/call_id/raw)、`OcrLayoutResult`(elements 含 bbox/type/page_index + page_sizes + 同套溯源五件;元素自 `_middle.json` para_blocks 全量提取,M3 设计 §1.2 取证)。全部 frozen dataclass。空结果语义:合法"无内容"用空值/None 表达,调用失败必须走异常——二者严格区分。 `ChatRequest`(model/messages/结构化输出参数/per-call 覆盖项)、`Usage`(tokens + elapsed,OCR 无计费填 0)、`OcrTextResult`(text + 溯源五件 source_name/usage/latency_ms/call_id/raw)、`OcrLayoutResult`(elements 含 bbox/type/page_index + page_sizes + 同套溯源五件;元素自 `_middle.json` para_blocks 全量提取,M3 设计 §1.2 取证)、`OcrDocumentResult`(1.3.11,#34: `OcrDocumentElement`=版面元素+`text`,加 page_sizes、整篇 `markdown` 与同套溯源五件;**独立定义不继承** `OcrLayoutResult`——父类 `call_stats` 带默认值,子类无默认字段会在导入期 `TypeError`)。全部 frozen dataclass。空结果语义:合法"无内容"用空值/None 表达,调用失败必须走异常——二者严格区分。
**`chat()` 公共签名定稿(2026-07-20,GovDoc 迁移缺口 G1/G2)**: `chat(messages, *, session_id=None, parent_call_id=None, cache_salt=None, cache_namespace=None, structured=None, stream=True)`。要点: ① `session_id`/`parent_call_id` 与三项目现有 `LLMProvider.chat` Protocol 逐字兼容——这是"调用点零改动"承诺的前提;② **per-call `cache_namespace`**: GovDoc 是单 client 服务多租户、tenant 每请求变化,装配级 namespace 只是默认值,per-call 传入时覆盖并进入缓存 key(§7.5);③ `cache_salt` per-call 可传(Video-Tree 跨 epoch 重采样);④ `structured` 三档语义(D14),类型定稿 `type[BaseModel] | Literal["json"] | None`(M1 设计): 不传 = 原始文本,`"json"` = 仅修复,pydantic 模型 = 完整阶梯(修复+形态校验+有界带反馈重问)。 **`chat()` 公共签名定稿(2026-07-20,GovDoc 迁移缺口 G1/G2)**: `chat(messages, *, session_id=None, parent_call_id=None, cache_salt=None, cache_namespace=None, structured=None, stream=True)`。要点: ① `session_id`/`parent_call_id` 与三项目现有 `LLMProvider.chat` Protocol 逐字兼容——这是"调用点零改动"承诺的前提;② **per-call `cache_namespace`**: GovDoc 是单 client 服务多租户、tenant 每请求变化,装配级 namespace 只是默认值,per-call 传入时覆盖并进入缓存 key(§7.5);③ `cache_salt` per-call 可传(Video-Tree 跨 epoch 重采样);④ `structured` 三档语义(D14),类型定稿 `type[BaseModel] | Literal["json"] | None`(M1 设计): 不传 = 原始文本,`"json"` = 仅修复,pydantic 模型 = 完整阶梯(修复+形态校验+有界带反馈重问)。
**`overlay` 追加(2026-07-31,issue #4)**: 签名末尾增 `overlay: Mapping[str, Any] | None = None`,承载采样参数(`temperature`/`seed`/`max_tokens` 等)。带默认值的 keyword-only 参数不改变既有调用点,"签名冻结"承诺不破。要点: ① 优先级 **结构化注入 > 调用级 overlay > 源级 `extra_body`**,由 `StructuredMW` 的 `{**request.overlay, **strategy_overlay}` 与 transport `_build_payload` 的 update 顺序天然给出,无新机制;② 保护键 `{model, messages, stream, stream_options}` 与不可 JSON 序列化的值在**进洋葱之前**报 `ValueError`(前者被覆盖会击穿成本换算/缓存口径/流式看门狗/usage 帧,后者会在 `CacheMW` 的降级 try 之外抛裸 `TypeError` 且一行遥测都没有);③ 同时填 `ChatRequest.sampling` 快照字段——`overlay` 在洋葱不同深度取值不同(内层含 `response_format`),缓存 key 与遥测需要一个跨层恒定的读取点,否则同一列在不同行口径分叉。 **`overlay` 追加(2026-07-31,issue #4)**: 签名末尾增 `overlay: Mapping[str, Any] | None = None`,承载采样参数(`temperature`/`seed`/`max_tokens` 等)。带默认值的 keyword-only 参数不改变既有调用点,"签名冻结"承诺不破。要点: ① 优先级 **结构化注入 > 调用级 overlay > 源级 `extra_body`**,由 `StructuredMW` 的 `{**request.overlay, **strategy_overlay}` 与 transport `_build_payload` 的 update 顺序天然给出,无新机制;② 保护键 `{model, messages, stream, stream_options}` 与不可 JSON 序列化的值在**进洋葱之前**报 `ValueError`(前者被覆盖会击穿成本换算/缓存口径/流式看门狗/usage 帧,后者会在 `CacheMW` 的降级 try 之外抛裸 `TypeError` 且一行遥测都没有);③ 同时填 `ChatRequest.sampling` 快照字段——`overlay` 在洋葱不同深度取值不同(内层含 `response_format`),缓存 key 与遥测需要一个跨层恒定的读取点,否则同一列在不同行口径分叉。
**调用方维度追加(2026-08-17,issue #11)**: 四个公共方法(`chat` / `embed` / `recognize_text` / `parse_layout`)签名末尾各增 `tenant_id: str | None = None` 与 `meta: Mapping[str, Any] | None = None`。同 `overlay` 的形态——带默认值的 keyword-only,既有调用点零改动,"签名冻结"承诺不破。要点: **调用方维度追加(2026-08-17,issue #11)**: 五个公共方法(`chat` / `embed` / `recognize_text` / `parse_layout` / `parse_document`)签名末尾各增 `tenant_id: str | None = None` 与 `meta: Mapping[str, Any] | None = None`。同 `overlay` 的形态——带默认值的 keyword-only,既有调用点零改动,"签名冻结"承诺不破。要点:
① **校验在公共入口抛 `ValueError`,不静默丢弃**(与 `overlay` 保护键同一先例:构造期错误,发生在洋葱之外,不入四分类)。规则:`tenant_id` ≤128 字符、不含首尾空白(**拒绝而非 strip**——`" t1"` 与 `"t1"` 在 RLS 的等值比较下是两个租户,替调用方改写等于把行藏进另一个租户且不报错)、不得空串(空串是"未归属"哨兵);`meta` ≤16 键,键匹配 `[a-z0-9_.]{1,64}` 且 `pg_` 前缀保留给库,值仅限 `str`/`int`/`float`/`bool`,字符串值 ≤256 字符,**非有限 float 必须挡在入口**(`json.dumps` 会把它写成裸 `NaN`/`Infinity` 字面量——不是合法 JSON,PG 的 JSONB 拒收;放行则写入失败被遥测的降级 try 吞成 warning,即调用方的输入错误转成静默丢遥测)。emitter 侧 `allow_nan=False` 是第二道闸,它保的是 **SQLite**:那边 `meta` 是 TEXT 列不做 JSON 校验,没有这道闸会把非法 JSON 静默存进去,而它抛出的异常同样被降级 try 接住 → 丢一行而非报错。 ① **校验在公共入口抛 `ValueError`,不静默丢弃**(与 `overlay` 保护键同一先例:构造期错误,发生在洋葱之外,不入四分类)。规则:`tenant_id` ≤128 字符、不含首尾空白(**拒绝而非 strip**——`" t1"` 与 `"t1"` 在 RLS 的等值比较下是两个租户,替调用方改写等于把行藏进另一个租户且不报错)、不得空串(空串是"未归属"哨兵);`meta` ≤16 键,键匹配 `[a-z0-9_.]{1,64}` 且 `pg_` 前缀保留给库,值仅限 `str`/`int`/`float`/`bool`,字符串值 ≤256 字符,**非有限 float 必须挡在入口**(`json.dumps` 会把它写成裸 `NaN`/`Infinity` 字面量——不是合法 JSON,PG 的 JSONB 拒收;放行则写入失败被遥测的降级 try 吞成 warning,即调用方的输入错误转成静默丢遥测)。emitter 侧 `allow_nan=False` 是第二道闸,它保的是 **SQLite**:那边 `meta` 是 TEXT 列不做 JSON 校验,没有这道闸会把非法 JSON 静默存进去,而它抛出的异常同样被降级 try 接住 → 丢一行而非报错。
@@ -470,7 +472,9 @@ flowchart TB
| HTTP 429(body 无 insufficient_quota)、500/502/503/504 | `TransientError`(携 `Retry-After` 解析值,仅支持秒数形态) | | HTTP 429(body 无 insufficient_quota)、500/502/503/504 | `TransientError`(携 `Retry-After` 解析值,仅支持秒数形态) |
| HTTP 429 + body 含 insufficient_quota、401、403 | `SourceDeadError` | | HTTP 429 + body 含 insufficient_quota、401、403 | `SourceDeadError` |
| HTTP 400 | `RequestRejectedError` | | HTTP 400 | `RequestRejectedError` |
| **空补全**: 200 且流程完整([DONE]/usage 正常)但 content 为空(2026-07-20 M1 验证发现,人类裁决) | `TransientError`(服务抖动,重试/换源;绝不缓存空响应) | | **空补全**: 200 且流程完整([DONE]/usage 正常)但 content 为空(2026-07-20 M1 验证发现,人类裁决) | `TransientError`(服务抖动,重试/换源;绝不缓存空响应)。1.3.10 起仅限**零产物**或无法区分抖动与结局的情形;上游明确的 length/过滤结局见 §15 |
| finish_reason 为 `network_error`/`error`(上游自报失败,1.3.10) | `TransientError` |
| 文本模式 length/过滤且无正文;保真模式 length 截断的半截工具(1.3.10) | `ResultInvalidError`(确定性结局,不重试、不计熔断) |
| 解析层失败(结构化输出/OCR ZIP) | `ResultInvalidError` | | 解析层失败(结构化输出/OCR ZIP) | `ResultInvalidError` |
**响应体留存(2026-08-16,Gitea issue #10;设计 `designs/2026-08-16-issue10-error-body-retention-design.md`)**: 上表每一条 HTTP 翻译**都必须携带响应体摘要**——摘要同时进入异常 message 与 `PolyGatewayError.body_text`(1.2.0 新增基类字段)。两者都要,因为逐次遥测写的是 `str(exc)`,只加字段进不了遥测表,而"事后可查"正是这条要求的目的。摘要口径由 `transports/_http_errors.summarize_body` 单点实现(折叠空白 → 限长 2048 字符 → 超长保留头 1400 + 尾 600 并记省略字数),两个 transport 共用,**不得各写一份**——issue #10 的成因正是"只有 429 那一支用了响应体"。`body_text` 是旁路数据,不参与任何治理判定;`_translate_429` 的类型细分仍解析未截断原文(摘要会破坏 JSON,改用它会让超长 body 的 `insufficient_quota` 退化成普通限速)。 **响应体留存(2026-08-16,Gitea issue #10;设计 `designs/2026-08-16-issue10-error-body-retention-design.md`)**: 上表每一条 HTTP 翻译**都必须携带响应体摘要**——摘要同时进入异常 message 与 `PolyGatewayError.body_text`(1.2.0 新增基类字段)。两者都要,因为逐次遥测写的是 `str(exc)`,只加字段进不了遥测表,而"事后可查"正是这条要求的目的。摘要口径由 `transports/_http_errors.summarize_body` 单点实现(折叠空白 → 限长 2048 字符 → 超长保留头 1400 + 尾 600 并记省略字数),两个 transport 共用,**不得各写一份**——issue #10 的成因正是"只有 429 那一支用了响应体"。`body_text` 是旁路数据,不参与任何治理判定;`_translate_429` 的类型细分仍解析未截断原文(摘要会破坏 JSON,改用它会让超长 body 的 `insufficient_quota` 退化成普通限速)。
@@ -501,6 +505,10 @@ flowchart TB
指数退避 + jitter: `delay = min(base * 2^attempt, max_delay) * uniform(0.5, 1.5)`,与 `Retry-After` 提示取较大值。**单层重试原则**: 库内只有 RetryMW 一层重试;Video-Tree/GovDoc 现存的"治理层重试 + Agent 步级二次重试"双层结构(异常集合不同、职责重叠)不复制——业务侧如需任务级重试,自行在库外包,且语义是"任务重试"不是"调用重试"。 指数退避 + jitter: `delay = min(base * 2^attempt, max_delay) * uniform(0.5, 1.5)`,与 `Retry-After` 提示取较大值。**单层重试原则**: 库内只有 RetryMW 一层重试;Video-Tree/GovDoc 现存的"治理层重试 + Agent 步级二次重试"双层结构(异常集合不同、职责重叠)不复制——业务侧如需任务级重试,自行在库外包,且语义是"任务重试"不是"调用重试"。
**生成时间净口径与沉默归因(2026-09-11,1.3.8,issue #27;设计 `designs/2026-09-11-streaming-stall-design.md` §4)**: `CallStats.generation_ms` 自 1.3.8 起为**净生成时间** = 赢家那次 transport 调用墙钟 − Σ(所有 > `stall_threshold_ms`(默认 1000,`SourceConfig` 字段,env 键 / `GatewaySettings` 字段 / 构造 kwarg 三层可调,无 per-call 覆盖)的相邻**增量 chunk**(`content` 或 `reasoning_content` 均计,与看门狗活性口径一致;空 chunk/usage 帧/SSE keepalive 不计时)沉默,**仍含 TTFT 与尾部**(末 chunk→流结束归活跃侧,墙钟基底口径的有意选择);1.3.7 的墙钟口径由 `generation_ms + stall_ms` 还原,语义突变经人类"窗口期最小代价"裁决放行(字段发布仅 2 天/消费者唯一/遥测无账本)。归因两字段 `stall_ms`/`stall_count`(沉默总额/超阈值相邻间隔个数,成对报告——Gregg off-CPU 原则);非流式/增量 chunk <2 → None 且不退减(None = 无沉默观测,下游以 `stall_ms is None` 分支);对冲输家的沉默不进赢家快照(落选者在其 attempt 遥测行,被取消者 `hedge_cancelled` 行恒 None)。**换算唯一处纪律(N8)**: TransportResult 携带未取整 `float` stall 值,取整与净额计算(`net = max(0, wall_ms − int(stall_ms or 0))`,clamp 防御)全库只在 retry 层一处,各实现不得另取整;钟同源(N4)——沉默序列(transport `time.monotonic`)与墙钟(retry 注入 `now`,生产即 `time.monotonic`)同钟,FakeClock 测试配自控 stall 值的假 transport。观测原语: transport 热路径只做"记时间戳 + set `content_event`",一切计量与触发判定在热路径外从该序列派生。扣沉默是库自造口径,NVIDIA/vLLM 基准口径均不扣沉默,不得混写。
**中段沉默对冲(2026-09-11,1.3.8,issue #28;设计同上 §5)**: `{SCOPE}__HEDGE__STALL_AFTER_S`(默认关闭)与 1.3.7 `{SCOPE}__HEDGE__AFTER_S` **两阈值独立生效**(可独设可组合);`_past_hedge_window` 为**两阶段触发窗**——阶段 1 首 token 前: AFTER_S 已设才计时触发(仅设 stall 时此阶段不触发,但仍须等首 token 到达才进阶段 2,否则 pre-TTFT 沉默会被 stall 窗误吞);阶段 2 首 token 后: STALL_AFTER_S 循环等待 `content_event`,超时到期复检 `is_set()` 防 chunk 与超时同 pass 误触发、check→clear 之间无 await、每轮自建 waiter 并逐字保留 waiter 收口 + `cancelling()` 穿透纪律;仅设 stall 且请求非流式 → `__call__` 入口门逐字走单路(中段沉默无非流式概念)。触发后编排/记账/遥测全复用 1.3.7(异源、完整准入、准入失败静默放弃、输家取消 est 保留不喂熔断、`hedge_cancelled` 行、两败计一次预算);对冲路 `content_event` 恒 None(v1 不梯次)。**与看门狗的交互**: 主路沉默超 `inter_token_timeout_s` 被掐死时,对冲路已在跑 → 本轮超级尝试不因主路之死而败(等副路裁定),attempt 级记账(breaker `record_failure`/`attempt_fails`/遥测 error 行)照常。**不对称声明(N9)**: 看门狗掐死**喂**熔断失败(1.3.6 起既有语义)vs 对冲输家取消**不喂**熔断(1.3.7 已批准语义)——同为源沉默事件不同处置,本版不改任一,运维"挂起率"统计须知此不对称。装配硬守卫: `stall_after_s ≥ min over sources of (inter_token_timeout_s or timeout_s)`(有效值,对冲窗永不开)或 `≥ call_deadline_s` → ValueError;单源 scope → warning 允许;Embedding/OCR scope 配任一对冲键 → `__post_init__` warning(B4,装配期可证明纯非流式;chat 的流式性是 per-call 参数,装配期无信号,故 chat 侧无此 warning)。**诚实标注: 中段沉默触发并发副本无业界公开先例**(主流收敛为看门狗掐流 → 重试),属超业界现状能力,人类已明确接受。
### 7.3 限流 ### 7.3 限流
**语义契约**(两后端同一套): `try_acquire(source, est_tokens) -> Permit | 拒绝(含等待提示)`;`Permit.settle(actual_usage)` 按实际 usage 结算(预扣保守入场,多退少补);`Permit.release()` 在 finally 必然执行;permit 带 TTL 租约防进程死亡泄漏。 **语义契约**(两后端同一套): `try_acquire(source, est_tokens) -> Permit | 拒绝(含等待提示)`;`Permit.settle(actual_usage)` 按实际 usage 结算(预扣保守入场,多退少补);`Permit.release()` 在 finally 必然执行;permit 带 TTL 租约防进程死亡泄漏。
@@ -562,7 +570,9 @@ flowchart TB
### 7.8 遥测与成本 ### 7.8 遥测与成本
**必录字段**(继承三项目 15 字段规范;当前 36 个 INSERT 字段,物理表列 37 = 36 + 数据库自填的 `created_at`,两套口径的区分见 `telemetry/schema.py` 模块 docstring): call_id、parent_call_id、session_id、model、provider、source_name、messages(JSON)、response、thinking、prompt_tokens、completion_tokens、usage_source、latency_ms、ttft_ms、max_inter_token_ms、cache_hit、error、**cost**、**cached_prompt_tokens**、**model_reported**、**sampling**、**reasoning_tokens**、**tenant_id**、**meta**、**thinking_observation**、**reasoning_effort**、**scope**、**operation**、**logical_call_id**、**event_kind**、**http_status_code**、**error_type**、**cause_type**、**error_body**、**attempts**、**total_latency_ms**。 **必录字段**(继承三项目 15 字段规范;当前 39 个 INSERT 字段,物理表列 40 = 39 + 数据库自填的 `created_at`,两套口径的区分见 `telemetry/schema.py` 模块 docstring): call_id、parent_call_id、session_id、model、provider、source_name、messages(JSON)、response、thinking、prompt_tokens、completion_tokens、usage_source、latency_ms、ttft_ms、max_inter_token_ms、cache_hit、error、**cost**、**cached_prompt_tokens**、**model_reported**、**sampling**、**reasoning_tokens**、**tenant_id**、**meta**、**thinking_observation**、**reasoning_effort**、**scope**、**operation**、**logical_call_id**、**event_kind**、**http_status_code**、**error_type**、**cause_type**、**error_body**、**attempts**、**total_latency_ms**、**stall_ms**、**stall_count**、**stream_end**(1.3.10,§15)。
**`stall_ms`/`stall_count` 两列(2026-09-11,1.3.8,issue #27,端口 36 → 38)**: 沉默归因进库,口径见 §7.2 净口径段。取 INTEGER 而非 REAL——落库值是 retry 层已取整的 int,与换算唯一处纪律(N8)一致。追加在 `total_latency_ms` 之后,可空无默认、旧行 NULL 不回填;`emit_cache_hit`/`emit_terminal_failure` 与失败/取消 attempt 行恒 NULL(缓存命中与终态行无 transport 观测;被取消的对冲输家无 TransportResult,其 `hedge_cancelled` 行 stall 恒 NULL,存在性由该行表达)。`generation_ms` **仍不进遥测列**(YAGNI)——墙钟由 `generation_ms + stall_ms` 还原。
**`sampling` 列(2026-07-31,issue #4,端口 20 → 21)**: 列语义 = 「调用方采样意图 ⊎ 生效源 `extra_body`」的 canonical JSON,空则 NULL。**不含**结构化注入的 `response_format`——列名是采样参数,schema 不是,且数 KB schema 逐行落库会让审计表无谓膨胀。三个 emit 入口口径必须各自定死,否则同一列在不同行含义不同: `emit_attempt`(RetryMW 调用,**唯一**有生效源者)并上 `source.extra_body`;`emit_cache_hit` / `emit_terminal_failure`(前者由 TelemetryMW 最外层调用,后者自 1.3.5 起由三个 client 的公开边界经 `emit_terminal_once` 统一写出)无 source 可言,只记调用级——与 `model`/`source_name` 在终态行置空是同一先例,且缓存命中行无损(`sampling` 已进缓存 key,能命中即意味调用级参数与历史那次逐字相同)。三者统一读 `request.sampling` 而非 `request.overlay`(后者在 RetryMW 处已被结构化注入污染、在 TelemetryMW 处未被污染,直接用必然三行分叉)。OCR/embedding 路径因决策 G 剥离 `extra_body`,该列恒 NULL。 **`sampling` 列(2026-07-31,issue #4,端口 20 → 21)**: 列语义 = 「调用方采样意图 ⊎ 生效源 `extra_body`」的 canonical JSON,空则 NULL。**不含**结构化注入的 `response_format`——列名是采样参数,schema 不是,且数 KB schema 逐行落库会让审计表无谓膨胀。三个 emit 入口口径必须各自定死,否则同一列在不同行含义不同: `emit_attempt`(RetryMW 调用,**唯一**有生效源者)并上 `source.extra_body`;`emit_cache_hit` / `emit_terminal_failure`(前者由 TelemetryMW 最外层调用,后者自 1.3.5 起由三个 client 的公开边界经 `emit_terminal_once` 统一写出)无 source 可言,只记调用级——与 `model`/`source_name` 在终态行置空是同一先例,且缓存命中行无损(`sampling` 已进缓存 key,能命中即意味调用级参数与历史那次逐字相同)。三者统一读 `request.sampling` 而非 `request.overlay`(后者在 RetryMW 处已被结构化注入污染、在 TelemetryMW 处未被污染,直接用必然三行分叉)。OCR/embedding 路径因决策 G 剥离 `extra_body`,该列恒 NULL。
@@ -586,7 +596,7 @@ flowchart TB
| 列 | 语义 | | 列 | 语义 |
| --- | --- | | --- | --- |
| `scope` | 池名,`TelemetryEmitter` 构造期注入(三个 client 各一行),三类行都带;**不拿 `source_name` 顶替**(终态失败可能根本没选出源) | | `scope` | 池名,`TelemetryEmitter` 构造期注入(三个 client 各一行),三类行都带;**不拿 `source_name` 顶替**(终态失败可能根本没选出源) |
| `operation` | `chat` / `embed` / `recognize_text` / `parse_layout`,**由调用点给定**。它与 `PolyGatewayError.operation` 是两个语义: 后者是异常自报的出错环节(可为 `download_result` 这类子步骤),链路上任何位置不得读它来填本列 | | `operation` | `chat` / `embed` / `recognize_text` / `parse_layout` / `parse_document`,**由调用点给定**。它与 `PolyGatewayError.operation` 是两个语义: 后者是异常自报的出错环节(可为 `download_result` 这类子步骤),链路上任何位置不得读它来填本列 |
| `logical_call_id` | 一次逻辑调用一个 ID;上下文缺席(库内现场构造的请求)落 NULL,**不造 ID** | | `logical_call_id` | 一次逻辑调用一个 ID;上下文缺席(库内现场构造的请求)落 NULL,**不造 ID** |
| `event_kind` | 三态: `attempt` / `cache_hit` / `terminal_failure`,三类行的唯一机械判据 | | `event_kind` | 三态: `attempt` / `cache_hit` / `terminal_failure`,三类行的唯一机械判据 |
| `http_status_code` / `cause_type` / `error_body` | 只在失败的 `attempt` 行上非空(成功行不统一填 200: 那会让"有状态码"不再等价于"失败了");终态行三列恒 NULL(**C1 红线**) | | `http_status_code` / `cause_type` / `error_body` | 只在失败的 `attempt` 行上非空(成功行不统一填 200: 那会让"有状态码"不再等价于"失败了");终态行三列恒 NULL(**C1 红线**) |
@@ -660,6 +670,7 @@ flowchart TB
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `OcrTextPort.recognize_text(image: bytes)` | `POST /ocr/text` | multipart 上传 → JSON `{content}` | `OcrTextResult`(多行纯文本) | | `OcrTextPort.recognize_text(image: bytes)` | `POST /ocr/text` | multipart 上传 → JSON `{content}` | `OcrTextResult`(多行纯文本) |
| `OcrLayoutPort.parse_layout(image: bytes)` | `POST /parse` | multipart → JSON(download_url) → GET ZIP → 解包 `*_middle.json` | `OcrLayoutResult`(elements 含 bbox + page_size) | | `OcrLayoutPort.parse_layout(image: bytes)` | `POST /parse` | multipart → JSON(download_url) → GET ZIP → 解包 `*_middle.json` | `OcrLayoutResult`(elements 含 bbox + page_size) |
| `OcrDocumentPort.parse_document(document: bytes)`(1.3.11,#34) | `POST /parse`(同上两段协议) | 上传类型按内容判定;流式 GET(整包 ≤256 MiB)→ 线程内解析 `*_middle.json` + 配对 `.md` | `OcrDocumentResult`(逐块 text + bbox + 页码,整篇 markdown) |
设计要点:输入统一 `bytes`(路径读取/多帧批量拼接留业务侧);bbox 返回 OCR 原生页面坐标,几何映射(裁剪偏移/归一化/marker 推算)留业务侧;`None`/空表达"合法无内容",异常表达"调用失败";ZIP 内容不可解析抛 `ResultInvalidError`(坏图≠坏服务);OCR 复用同一套治理算法件、循环形态同 EmbeddingClient 先例(见 D9 修订;无 token 计费,Usage 填 0,latency_ms 照记);多后端经 provider 注册表扩展(GLM 已在 CHSAnalyzer 白名单,输入形态为 URL,届时封装在其 invoker 内部,端口签名不变)。数值防御(bbox 有限性/顺序/退化校验)随协议解析下沉进库。OCR transport 支持 per-source `trust_env` 开关(LAN 直连绕过本地代理,Video-Tree `ocr.py:46` 教训;2026-07-20 缺口 R9);客户端暴露 `check_health() -> dict[str, bool]` 逐源健康预检(M3 设计 §3.3 细化:bool 无法承载逐源结果,业务 `all()` 即得启动门布尔;Video-Tree A/B 评测消费;缺口 R10 已闭)。协议事实(2026-07-21 实测): `/parse` 的 download_url 为**相对路径**(`/static/*.zip`),transport 相对 base_url 解析并兼容绝对 URL。 设计要点:输入统一 `bytes`(路径读取/多帧批量拼接留业务侧);bbox 返回 OCR 原生页面坐标,几何映射(裁剪偏移/归一化/marker 推算)留业务侧;`None`/空表达"合法无内容",异常表达"调用失败";ZIP 内容不可解析抛 `ResultInvalidError`(坏图≠坏服务);OCR 复用同一套治理算法件、循环形态同 EmbeddingClient 先例(见 D9 修订;无 token 计费,Usage 填 0,latency_ms 照记);多后端经 provider 注册表扩展(GLM 已在 CHSAnalyzer 白名单,输入形态为 URL,届时封装在其 invoker 内部,端口签名不变)。数值防御(bbox 有限性/顺序/退化校验)随协议解析下沉进库。OCR transport 支持 per-source `trust_env` 开关(LAN 直连绕过本地代理,Video-Tree `ocr.py:46` 教训;2026-07-20 缺口 R9);客户端暴露 `check_health() -> dict[str, bool]` 逐源健康预检(M3 设计 §3.3 细化:bool 无法承载逐源结果,业务 `all()` 即得启动门布尔;Video-Tree A/B 评测消费;缺口 R10 已闭)。协议事实(2026-07-21 实测): `/parse` 的 download_url 为**相对路径**(`/static/*.zip`),transport 相对 base_url 解析并兼容绝对 URL。
@@ -783,3 +794,36 @@ src/polygateway/
| Q6 | CHSAnalyzer 的 judge 迁移路径 | **已拍板(2026-07-22 用户)**: M4 实测 judge/core-eval 评估流水线**零调用方、从未接线**(全仓仅自测消费),且实验室网关无 claude 系模型——本轮**豁免不动**,judge.py 原样保留;待评估流水线真正启用时再收编走库(届时裁判模型从网关现有模型选) | | Q6 | CHSAnalyzer 的 judge 迁移路径 | **已拍板(2026-07-22 用户)**: M4 实测 judge/core-eval 评估流水线**零调用方、从未接线**(全仓仅自测消费),且实验室网关无 claude 系模型——本轮**豁免不动**,judge.py 原样保留;待评估流水线真正启用时再收编走库(届时裁判模型从网关现有模型选) |
| Q4 | conda 环境名 | `PolyGateway` | | Q4 | conda 环境名 | `PolyGateway` |
| Q5 | 本仓库工程脚手架(git init、`.claude/` skills、Makefile、pyproject、import-linter)何时落地 | 本文档终审通过后、M1 编码前一次落地 | | Q5 | 本仓库工程脚手架(git init、`.claude/` skills、Makefile、pyproject、import-linter)何时落地 | 本文档终审通过后、M1 编码前一次落地 |
## 14. OpenAI 单 choice 消息保真(2026-10-01,#29 已批准增量)
本节明确修订 §5/§6.2/§7.1/§7.2/§7.5/§7.6 中原仅文本契约,详细边界见 [设计](designs/2026-10-01-issue29-openai-fidelity-design.md)。不新增 Agent Loop 或第二套治理。
| 接缝 | 增量契约 |
|---|---|
| 公共返回 | chat 追加 response_mode=text/openai,默认 text;LLMResponse/TransportResult 末尾追加 completion,内含 assistant message、finish_reason、response_id、usage;旧字段继续文本兼容投影 |
| 请求所有权 | messages 字段保真;overlay 仍受 model/messages/stream/stream_options 保护;raw-only 复用既有路径,模式不绕过 managed 校验 |
| 协议 | openai 单 choice;工具按 index 排序,name/arguments 逐片拼接;SSE 支持 role/content/reasoning_content/tool_calls,未知增量拒绝;非流式保留消息扩展字段;成功必含 assistant role |
| 失败 | 所有 chat 模式 error 帧立即 TransientError;openai 缺 DONE 一律重试、不 salvage;tool-only 成功,纯空/不完整工具失败;text salvage 原语义不变 |
| 活性 | 同帧正文/推理/工具实际进展只记一次;role/usage/finish/空帧/重复身份不算;沿既有看门狗、首 token、stall 对冲与唯一取整点 |
| 缓存 | 仅 openai key 新增 response_mode 语义维度,默认 text key 不变;完整快照显式重建,缺 completion/role 或无有效正文/工具按 miss(与 transport 共用内核纯值判据,不依赖 transport 实现);历史上游 id 回放,本次治理 id 重建 |
| 迁移 | 旧 text 污染无法自动判断,受影响集合必须迁移 namespace/salt;新模式天然隔离;无新遥测列/表,统一 helper 与取消结算不变 |
本增量不会执行工具;length 结束原因与参数原样交回,由下游判断可执行性。嵌套 JSON 是独立可变快照,不承诺深冻结。许可已由原创 owner 明确批准 Apache-2.0。
## 15. 统一完成判定(2026-10-14,#30/#31 已授权增量)
本节修订 §6.2 空补全行、§7 `usage_source` 映射与 §14 协议/失败/缓存三行,详细边界见 [设计](designs/2026-10-14-issue30-31-completion-verdict-design.md)。
| 接缝 | 增量契约 |
|---|---|
| 根因 | 1.3.9 以 `[DONE]` 存在、正文非空、字段白名单判定成功,上游正常结局落 Transient 被重试放大;本版以上游 `finish_reason` 与传输完整性为准 |
| 内核 | `types.py` 纯函数 `_stream_is_complete` / `_classify_finish` / `_judge_outcome` / `_is_cacheable`,流式、非流式、缓存读写四处共用;只返回判定值,错误由调用点翻译 |
| 流完整性 | `[DONE]` → `done`;无 DONE 但 finish 已到且其同帧或之后的 usage 为实测、干净 EOF → `eof_after_finish`;其余为截断:保真不打捞,文本按 `missing_done` |
| 结局 | 首匹配表:上游错误 finish → Transient;坏工具(length → ResultInvalid);零产物 + length/过滤 → 保真交付、文本 ResultInvalid;零产物其余 → Transient(保 2026-07-20 裁决);有正文交付;有产物无正文按 finish 分模式 |
| 字段 | 五层 null 视缺省;保真未知字段无损合并(字符串拼接、index 数组按 index、标签键幂等),冲突删键 + 有界告警;未知字符串片段计活性 |
| 缓存 | 可缓存 ⊂ 可交付:salvaged、无正文、length/过滤/未知 finish 不写,读到即 miss;判定先于命中覆盖 |
| 公共面 | `LLMResponse`/`TransportResult` 追加 `finish_reason`、`stream_end`;遥测加 `stream_end` 列,`record_llm_call` 加必填参数 |
`usage_source` 与 `stream_end` 分工:前者答"账单可信吗",后者答"内容完整吗"。`eof_after_finish ⇒ measured`;`salvaged ⇒ estimated|unavailable`;`estimated ⇒ salvaged`,逆命题不成立。诚实边界:中转在上游出错后主动正常关闭连接,与真完整在传输层不可区分。
+11 -3
View File
@@ -13,7 +13,7 @@
## 1. 总览与状态 ## 1. 总览与状态
| 阶段 | 内容 | 接入方 | 状态 | | 阶段 | 内容 | 接入方 | 状态 |
|---|---|---|---| | --- | --- | --- | --- |
| P0 奠基 | 调研、ARCHITECTURE.md、CLAUDE.md、skill Fable 5 改造、hooks 硬边界、脚手架(git/pyproject/Makefile/conda 环境/CI 绿) | — | ✅ 完成(2026-07-20) | | P0 奠基 | 调研、ARCHITECTURE.md、CLAUDE.md、skill Fable 5 改造、hooks 硬边界、脚手架(git/pyproject/Makefile/conda 环境/CI 绿) | — | ✅ 完成(2026-07-20) |
| M1 核心 | 内核类型 + httpx transport + 治理中间件(内存后端)+ 多源多账号 + 缓存 + SQLite 遥测 + 结构化输出 + from_env | GovDoc、Video-Tree | ✅ 完成(2026-07-20;239 测试全绿、覆盖 92%、真实网关+真实 Redis 验收通过、独立 verifier 问题清零;见 designs/2026-07-20-m1-core-design.md 状态行) | | M1 核心 | 内核类型 + httpx transport + 治理中间件(内存后端)+ 多源多账号 + 缓存 + SQLite 遥测 + 结构化输出 + from_env | GovDoc、Video-Tree | ✅ 完成(2026-07-20;239 测试全绿、覆盖 92%、真实网关+真实 Redis 验收通过、独立 verifier 问题清零;见 designs/2026-07-20-m1-core-design.md 状态行) |
| M2 分布式 | Redis 限流/熔断后端、多源 × Redis 联合验证、背压、Postgres 遥测、成本、Embedding(Q3)、压测 harness | CHSAnalyzer(治理) | ✅ 完成(2026-07-21;契约双后端全绿 + 时间语义真实等待变体 10/10、跨连接联合验证、CHS 对标十项打钩、P3 真实验收 300 次双进程七不变量 PASS、独立 verifier 0 Critical 且 Important 清零;见 designs/2026-07-20-m2-distributed-design.md 与 findings/m2-verifier-fixes.md。压测收官: P5 故障混编 40 次八不变量 PASS,P6 首跑 58.1% 暴露三大治理盲区(findings/2026-07-21-p6-soak-baseline.md)→ **M2.5 治理韧性**六轮迭代(双通道熔断/健康选源/AIMD/429 免预算/健康门槛降权/连败抑制)后同场景 **98.96%**、八不变量全 PASS,验收见 findings/2026-07-21-m25-acceptance.md 与 designs/2026-07-21-m25-resilience-design.md) | | M2 分布式 | Redis 限流/熔断后端、多源 × Redis 联合验证、背压、Postgres 遥测、成本、Embedding(Q3)、压测 harness | CHSAnalyzer(治理) | ✅ 完成(2026-07-21;契约双后端全绿 + 时间语义真实等待变体 10/10、跨连接联合验证、CHS 对标十项打钩、P3 真实验收 300 次双进程七不变量 PASS、独立 verifier 0 Critical 且 Important 清零;见 designs/2026-07-20-m2-distributed-design.md 与 findings/m2-verifier-fixes.md。压测收官: P5 故障混编 40 次八不变量 PASS,P6 首跑 58.1% 暴露三大治理盲区(findings/2026-07-21-p6-soak-baseline.md)→ **M2.5 治理韧性**六轮迭代(双通道熔断/健康选源/AIMD/429 免预算/健康门槛降权/连败抑制)后同场景 **98.96%**、八不变量全 PASS,验收见 findings/2026-07-21-m25-acceptance.md 与 designs/2026-07-21-m25-resilience-design.md) |
@@ -25,7 +25,7 @@
**内部开发顺序**(编号即依赖顺序,同号可并行): **内部开发顺序**(编号即依赖顺序,同号可并行):
| 步 | 内容 | 顺序理由 | | 步 | 内容 | 顺序理由 |
|---|---|---| | --- | --- | --- |
| 1 | `types.py` + `errors.py` + `ports.py` 全量设计与冻结 | 原则 2:公共承诺先行;这是 M1 设计文档(人类门)的主体 | | 1 | `types.py` + `errors.py` + `ports.py` 全量设计与冻结 | 原则 2:公共承诺先行;这是 M1 设计文档(人类门)的主体 |
| 2a | `streaming.py` 看门狗移植 | 原则 4:纯函数,零依赖,直接移植+补测 | | 2a | `streaming.py` 看门狗移植 | 原则 4:纯函数,零依赖,直接移植+补测 |
| 2b | `providers.py` 注册表 | 叶子模块;transport 的前置(思考流字段声明;thinking 注入的**决策**已于 issue #16/#17 搬到 `thinking.py`,这里只留形态声明) | | 2b | `providers.py` 注册表 | 叶子模块;transport 的前置(思考流字段声明;thinking 注入的**决策**已于 issue #16/#17 搬到 `thinking.py`,这里只留形态声明) |
@@ -68,9 +68,17 @@
音频端口实现(D10)、SDK transport(openai/anthropic 原生协议,D2 预留)、GLM OCR invoker(D9 预留)、内网 pip index(Q1)、多项目共用 Redis 的 namespace 治理、harness-eval 评估流水线激活。 音频端口实现(D10)、SDK transport(openai/anthropic 原生协议,D2 预留)、GLM OCR invoker(D9 预留)、内网 pip index(Q1)、多项目共用 Redis 的 namespace 治理、harness-eval 评估流水线激活。
**已交付增补(2026-09-10)**: 长尾对冲(issue #24,1.3.7)——chat 单路并发对冲,触发=流式首 token 未至/非流式纯时长阈值,异源走完整准入,默认关闭;`CallStats` 增 `generation_ms`(裸生成时间)/`hedges`/`hedge_won`。设计 designs/2026-09-10-24-hedged-requests-design.md(人类已批准 H1-H8),实施 plans/2026-09-10-24-hedged-requests.md。后续储备:梯次多路对冲(H5 预留)、分位数触发、embedding/OCR 对冲(现不做)。
**已交付增补(2026-09-14)**: 流式中段沉默计量与对冲(issue #27/#28,1.3.8)——`generation_ms` 口径重定义为**净生成时间**(墙钟 − Σ超阈值中段沉默,仍含 TTFT 与尾部;1.3.7 墙钟口径由 `generation_ms + stall_ms` 还原,披露见 CHANGELOG 未发布段),`CallStats` 增 `stall_ms`/`stall_count` 归因字段,遥测表加同两列(INSERT 字段 36→38);`{SCOPE}__HEDGE__STALL_AFTER_S` 中段沉默对冲(独立于 `HEDGE__AFTER_S` 生效、默认关闭;无业界公开先例,超业界现状能力),`{SCOPE}__STREAM__STALL_THRESHOLD_MS` 计量阈值(默认 1000,三层可调)。设计 designs/2026-09-11-streaming-stall-design.md(人类已批准 + 独立审查补遗 B1-B4/N1-N9),实施 plans/2026-09-11-streaming-stall.md,验证 findings/2026-09-11-streaming-stall-validation.md。后续储备:自适应分位阈值(防反馈环,现不做)、流式透传下的 commit 点重审、embedding/OCR 对冲(现不做)。
**已交付增补(2026-10-22,1.3.11)**: OCR 整份文档解析(issue #34,1.3.11)——`OcrClient.parse_document` + `OcrDocumentPort`/`OcrDocumentTransport`,PDF 上传按内容标注(同时修复 `recognize_text(PDF)` 的静默幻觉文本),结果只取自 `_middle.json` + 配对 `.md`;新路径对确定性坏输入归"请求被拒"。后续: #35(共享分类)、#36(`recognize_text` 不校验 `success`)、#37(`parse_layout` 原生异常漏出与 `download_url` 主机)。设计/计划见 `designs/2026-10-21-issue34-ocr-document-design.md`、`plans/2026-10-21-issue34-ocr-document.md`。
**已交付增补(2026-10-15,1.3.10 已发布)**: 统一完成判定(issue #30/#31,1.3.10)——以上游 `finish_reason` 与传输完整性判定流完成与结局,流式/非流式/缓存读写共用内核纯函数;缺 `[DONE]` 但 finish+实测 usage 完整即成功(两模式),文本 length/过滤无正文改 `ResultInvalidError`,保真模式 null/未知字段无损合并;`LLMResponse` 增 `finish_reason`/`stream_end`,遥测加 `stream_end` 列。设计 designs/2026-10-14-issue30-31-completion-verdict-design.md(人类授权 Claude 与独立 subagent 两轮审查后直接执行),实施 plans/2026-10-14-issue30-31-completion-verdict.md。后续储备:文本 n>1 混流修复、文本"只有思考+stop"证据收集。
## 7. 开放决策依赖 ## 7. 开放决策依赖
| 决策 | 阻塞点 | 需拍板时间 | | 决策 | 阻塞点 | 需拍板时间 |
|---|---|---| | --- | --- | --- |
| Q1 打包分发(git+ssh vs 内网 index) | 不阻塞开发,阻塞 M4 发布 | M4 前 | | Q1 打包分发(git+ssh vs 内网 index) | 不阻塞开发,阻塞 M4 发布 | M4 前 |
| Q3 Embedding 是否纳入 | ~~影响 M2 范围~~ **已拍板纳入 M2**(2026-07-20) | ✅ 已决 | | Q3 Embedding 是否纳入 | ~~影响 M2 范围~~ **已拍板纳入 M2**(2026-07-20) | ✅ 已决 |
@@ -85,7 +85,7 @@ class OcrTransport(Protocol): # 协议细节;不含治理
| 关切 | 决策 | 蓝本出处 | | 关切 | 决策 | 蓝本出处 |
|---|---|---| |---|---|---|
| HTTP 客户端 | per-source `httpx.AsyncClient`(惰性建,keyed by source.name),`trust_env=source.trust_env`、`timeout=source.timeout_s` | **R9 销账**: SourceConfig.trust_env + `TRUST_ENV` 配置键 M1 已落地,openai_compat.py:203 已尊重,本 transport 同款;VT LAN 直连绕代理场景配 `OCR__MONKEY__N__TRUST_ENV=false` | | HTTP 客户端 | per-source `httpx.AsyncClient`(惰性建,keyed by source.name),`trust_env=source.trust_env`、`timeout=source.timeout_s` | **R9 销账**: SourceConfig.trust_env + `TRUST_ENV` 配置键 M1 已落地,openai_compat.py:203 已尊重,本 transport 同款;VT LAN 直连绕代理场景配 `OCR__MONKEY__N__TRUST_ENV=false` |
| multipart 上传 | `files={"file": ("image.jpg", image, "image/jpeg")}` 固定名(CHS invokers.py:496 原样;服务按内容处理,35 样本实测通过,核验输出存档 `data/soak/m3_xcheck_results.txt`) | CHS | | multipart 上传 | `files={"file": ("image.jpg", image, "image/jpeg")}` 固定名(CHS invokers.py:496 原样;服务按内容处理,35 样本实测通过,核验输出存档 `data/soak/m3_xcheck_results.txt`)。**勘误(2026-10-22,#34)**: "服务按内容处理"只在图片样本上验证过,**不适用于 PDF**——服务按文件名扩展名分派(`.pdf`/`.jpg`/`.jpeg`/`.png`),PDF 标 `image.jpg` 时 `/ocr/text` 返回 `success:true` 的幻觉文本;上传标签现按内容判定,见 `2026-10-21-issue34-ocr-document-design.md` §1 E3/E4/E10 | CHS |
| `/ocr/text` 解析 | 2xx + JSON + `content` 必须为 str,缺失/非 str → `ResultInvalidError`;空串合法 | VT(`.get("content","")` 的静默兜底**有意替换**为显式校验——P5 严禁默认值掩盖错误) | | `/ocr/text` 解析 | 2xx + JSON + `content` 必须为 str,缺失/非 str → `ResultInvalidError`;空串合法 | VT(`.get("content","")` 的静默兜底**有意替换**为显式校验——P5 严禁默认值掩盖错误) |
| `/parse` 两段协议 | POST `/parse` → 校验 `success is True` + `download_url` 非空 str → GET(相对路径 urljoin base_url,绝对 URL 原样)→ ZIP | CHS invokers.py:489-530 逐段保真;相对路径显式化(取证 §1.2) | | `/parse` 两段协议 | POST `/parse` → 校验 `success is True` + `download_url` 非空 str → GET(相对路径 urljoin base_url,绝对 URL 原样)→ ZIP | CHS invokers.py:489-530 逐段保真;相对路径显式化(取证 §1.2) |
| `_middle.json` 校验 | ZIP 内首个 `*_middle.json`;`pdf_info` 必须 list;每页: dict、`page_size` 为 2 正有限数、`para_blocks` 缺省按空列表、每块 type 为 str、bbox 为 4 有限数且 x2>x1、y2>y1 且**整数化后不退化**(库返回 float 原生 bbox,此校验专为 CHS shim 的 `int()` 裁剪路径兜底——亚像素宽的退化框裁剪即空图,实现中须注释此由来防止被当死代码删除) | CHS `_finite_number`/`_parse_table_result`(invokers.py:427-479)数值防御**全量下沉**,提取面从"首表"泛化为"全元素"(取证 §1.2 证明无损) | | `_middle.json` 校验 | ZIP 内首个 `*_middle.json`;`pdf_info` 必须 list;每页: dict、`page_size` 为 2 正有限数、`para_blocks` 缺省按空列表、每块 type 为 str、bbox 为 4 有限数且 x2>x1、y2>y1 且**整数化后不退化**(库返回 float 原生 bbox,此校验专为 CHS shim 的 `int()` 裁剪路径兜底——亚像素宽的退化框裁剪即空图,实现中须注释此由来防止被当死代码删除) | CHS `_finite_number`/`_parse_table_result`(invokers.py:427-479)数值防御**全量下沉**,提取面从"首表"泛化为"全元素"(取证 §1.2 证明无损) |
@@ -0,0 +1,313 @@
# 1.3.6:可选调用期限(issue #22),兼 `Retry-After` 有限值防御
- 状态: **人类已于 2026-09-10 批准**(§9 七项批准项全数获批,H3 取 (a′) 档);实施计划见 `research-wiki/plans/2026-09-10-136-call-deadline.md`
- 基线: main `ab00aa4` / 1.3.5,工作区 HEAD `d2455e8`;分支 `feature/1.3.6-call-budgets`
- 范围收敛(2026-09-09 人类定夺,2026-09-10 追认): **只做方案 B 的后半**——保留 chat 的 429 免次数预算,新增**可选**整体调用期限,缺省不启用;**不做** 429 次数预算(原 D2/D3)、**不做** issue #24(未批,不得捆绑)
- 已批准契约(2026-09-10 逐条定案): `call_deadline_s` 缺省 `None`;单调用参数 `None` = 继承装配值;`CallDeadlineExceeded` 为 `PolyGatewayError` **直接**子类;合作式清理会超出期限、**到期可能丢弃已计费的成功**;chat 429 免次数预算保留原样
- 取消结算(TPM)修复**并入本版**: 不再作为「先立独立 issue、修好再启用期限」的前置阻塞,改为本版第一个原子提交(§6.3 矩阵即其精确契约)
- 输入: issue #22 原文;设计审查 `a544b789/design136/review.md`(BL1-BL5)与独立复审 B(B1-B5,含离线 asyncio 探针实测);本轮独立探针 `/tmp/pgw_deadline_probe.py`、`/tmp/pgw_deadline_probe2.py`(3.12.13 实测,§5.2);1.3.5 源码逐行现读
- 关联: ARCHITECTURE §7.2/§7.3、`designs/2026-08-06-issue8-stall-budget-design.md`、`designs/2026-09-09-135-call-observability-design.md`
## 1. 目标与非目标
| 项 | 内容 |
| --- | --- |
| 目标 1 | 让调用方能对**一次逻辑调用**设墙钟上限;不配置时,库行为逐字保持 1.3.5 |
| 目标 2 | 修 `Retry-After` **非有限值**防御缺口(`inf`/`1e999` → `sleep(inf)` 永久挂起) |
| 目标 3 | 期限是**单一硬边界**,覆盖缓存 IO/准入排队/退避 sleep/transport/结构化重问/embedding 分批,不是轮首软检查 |
| 非目标 A | 不新增 429 次数预算、不改 429 退避指数分账、不收紧任何缺省(原 §4.1/§4.2 已删除) |
| 非目标 B | 不改三条循环各自的既有差异(chat 429 免预算 + stall 退还;embed/OCR 无条件计数) |
| 非目标 C | ~~本设计不含取消路径 `settle(0)` 结算修复~~ → **2026-09-10 改判**: 该修复**已获批并并入本版**,是期限落地前的第一个原子提交(§6.3 给出精确结算矩阵)。它只改**取消路径**的结算取值,不动成功/拒绝/失败三条既有分支的口径 |
| 非目标 D | 不做 issue #24(长尾对冲);不新增遥测列;不新增后台任务/`shield` |
## 2. 1.3.5 现状核实(现读源码,不引用旧报告)
| 事实 | 证据 | 对本设计的意义 |
| --- | --- | --- |
| chat 429 免重试预算并退还 stall 账 | `middleware/retry.py:158-164`、`:250-257` | **保留不动**;期限是正交的第二层 |
| embed/OCR 无条件 `fails += 1` | `embedding.py:319`、`ocr.py:339` | **保留不动**;两条循环次数有界、时长无界,由期限兜 |
| 退避与 `Retry-After` 取大且不夹上限 | `retry.py:64-77` `max(delay, retry_after)` | 有限大值**照睡**(§6.2 说明为何不夹) |
| `_parse_retry_after` 放行 `inf`,但 `nan` **已被忽略** | `transports/openai_compat.py:111-120`:`float()` 成功后 `seconds > 0`;`nan > 0` 恒假 → `nan` 现已返回 `None` | 真实缺口只有 `inf`/`1e999` 一族(唯一一处;`monkey_ocr.py:58` 不解析该头) |
| 三个公开边界均在**输入校验之后**建 `_CallContext` | `client.py:381`、`embedding.py:192`、`ocr.py:272` | 期限起点与 `total_latency_ms` 同口径,校验时间不计入 |
| 洋葱与两条循环全在同一 `await` 树下 | `client.py:398`、`embedding.py:209`、`ocr.py:274` | 一个 `asyncio.timeout` 即可覆盖全部等待,**无须**改 `backoff_delay`/`SourceAdmission._nap` 签名(解 BL3) |
| 终态行唯一出口 + 去重 | `telemetry.py:670-716` `emit_terminal_once` / `types.py:355` `claim_terminal` | 到期终态复用该出口,`error_type` 列自动落新类名,**无新增列** |
| 取消路径 attempt 行写 `error="cancelled"` 后穿透 | `retry.py:320-323` | 到期时 attempt 行仍记 cancelled(保留),**终态行**必须记 deadline |
| 库内已有 `asyncio.timeout` + `cm.expired()` 范式 | `streaming.py:43-58`、`telemetry/postgres.py:419-423` | 本设计沿用同一范式,不发明新写法 |
| `asyncio.timeout` 的**公共契约**: 到期投递取消并在退出时转 `TimeoutError`,外部取消原样上抛,擦边成功不遗留游离取消 | 标准库文档 + 受支持 Python 矩阵上的行为测试(§10"形态区分"批次;复审 B 在 3.12.13 上以离线探针复核) | 外部取消优先与"擦边成功"竞态由运行时判定,库不自判(§5.2);**不依据 CPython 私有实现细节立论**,跨版本保证由测试矩阵给 |
## 3. 实现范式的三个备选
| 方案 | 做法 | 判定 |
| --- | --- | --- |
| **A 单一硬边界(推荐)** | 在三个公开边界各用一次 `asyncio.timeout(d)` 包住整条 `await` 树;到期由运行时取消在途 await,边界处转成 `CallDeadlineExceeded` | 覆盖面天然完整(缓存/准入/sleep/transport/重问/分批);零签名扩散;代价是**到期即取消在途尝试**(§6.3) |
| B 轮首软检查 + sleep 夹紧 | 三条循环轮首判剩余预算,退避 `min(delay, 剩余)` | **否决**: 名为期限实为"轮次粒度上限"——一次 300s 的慢尝试或一次准入排队即可整体越限,且要改 `backoff_delay`、`_nap`、三处轮首共 5 个点(BL3 原形态)。用软检查冒充硬期限正是 issue #8 "不要用一个预算冒充另一个预算"的同形错误 |
| C 每层各自超时 | 缓存、准入、transport 各配一份超时 | **否决**: N 个键相加才是总时长,调用方仍拿不到"最多等 N 秒"的承诺;配置面爆炸 |
**推荐 A**,且缺省 `None` = 不启用:opt-in 才能保证存量下游行为逐字不变(1.3.x 内不做默认行为变更)。
## 4. 方案 A 的具体形态
### 4.1 新模块 `src/polygateway/deadline.py`(约 50 行,只依赖 stdlib + `errors.py`)
```python
def ensure_call_deadline(value: object, origin: str) -> float | None:
"""全装配路径共用的值域校验: None 或有限正数,否则 ValueError(消息含 origin)。"""
if value is None:
return None
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValueError(...) # bool 先判:不得把 True 当成 1 秒
v = float(value)
if not math.isfinite(v) or v <= 0:
raise ValueError(...) # NaN / inf / 0 / 负
return v
async def with_call_deadline[T](aw: Awaitable[T], *, deadline_s: float | None, scope: str) -> T:
"""给一次逻辑调用施加单一硬边界;None = 不进任何上下文,逐字走旧路径。
入参已由调用方在**构造 `aw` 之前**校验(见 §4.3),本函数不再校验。
"""
if deadline_s is None:
return await aw
inner_timeout: BaseException | None = None
try:
async with asyncio.timeout(deadline_s) as cm:
try:
return await aw
except TimeoutError as exc:
inner_timeout = exc # 体内(含清理路径)自抛,不是本层期限
raise
except TimeoutError as exc:
if cm.expired() and exc is not inner_timeout:
raise CallDeadlineExceeded(scope=scope, deadline_s=deadline_s) from None
raise # 内层失败原样上抛,绝不贴 deadline 标签
```
**为何不能只用 `cm.expired()`(本轮独立探针实测,3.12.13)**: `Timeout.expired()` 在 `EXPIRING`/`EXPIRED` 两态都返 True(`inspect.getsource` 现读),而计时器触发后的**清理路径**若自抛 `TimeoutError`(第三方端口实现自抛,或其内部另一个 `asyncio.timeout` 到期),该异常会被只看 `expired()` 的写法**改标成 `CallDeadlineExceeded`**(探针 E1/E2 实测均为误标)。`__cause__` 启发式也不够: 内层 `asyncio.timeout` 抛的 `TimeoutError` 其 `__cause__` 同样是 `CancelledError`(E1 实测仍误标)。故采用**局部变量身份比较**这一最小辅助机制: 本层 `asyncio.timeout` 转出的是 `raise TimeoutError from exc_val` 新建对象,与体内那个实例必不同一,判据确定、不依赖任何 CPython 私有实现。它**不新增公共配置、不开后台任务、不改异常对象**;`cm.expired()` 作为第二道守卫保留。
```text
探针证据(/tmp/pgw_deadline_probe2.py, python 3.12.13):
D1 到期命中 → CallDeadlineExceeded 耗时 0.050s cancelling=0
D2 清理内层 timeout → TimeoutError(原样) 耗时 0.060s ← 只看 expired() 会误标
D3 清理裸 TimeoutError→ TimeoutError(原样) 耗时 0.050s ← 只看 expired() 会误标
D4 未到期内层自抛 → TimeoutError(原样) 耗时 0.010s
D5 到期窗口内领域异常 → 领域异常(期限让位) 耗时 0.200s
D6 慢清理 → CallDeadlineExceeded 耗时 0.251s(期限 5×)
D7 清理期外部取消 → CancelledError cancelling=1(外部取消优先)
D8 外部取消先到 → CancelledError
D9 未启用(None) → 逐字旧路径
```
写成**接收 awaitable 的函数**而非 `@asynccontextmanager`: 后者要把 `yield` 包进 timeout,取消经 `athrow` 回注生成器,语义正确但绕(`streaming.py:60-70` 的 docstring 已记录同类陷阱);函数形态只有一条直路。`None` 分支**不进** `asyncio.timeout`,故未启用时连"必须在 Task 内运行"这一新约束都不引入。
**两个实现约束(实施时不得变形)**:
1. **校验先于构造 awaitable**: 公开方法入口先跑 `ensure_call_deadline`,通过后才构造 `self._handler(request)` 等协程。若把校验放进 `with_call_deadline`,非法参数抛错时会遗留**未 await 的协程**(RuntimeWarning + 未释放资源)。
2. **分层合法**: `deadline.py` 只 import stdlib 与 `errors.py`,并作为**新一层**进 import-linter 契约(`pyproject.toml` layers 中置于 `polygateway.thinking` 之下、内核行之上)。`config.py` 位在更上层,import 它合法且**不会依赖任何具体实现**(transports/backends/telemetry 一律不引入)。
### 4.2 三处接入点(唯一三处;严禁在循环内层再建 scope)
| 文件:行 | 现状 | 改后 |
| --- | --- | --- |
| `client.py:398` | `response = await self._handler(request)` | `await with_call_deadline(self._handler(request), deadline_s=d, scope=self._scope)` |
| `embedding.py:209` | `return await self._embed_all(...)` | 同款包住 `_embed_all(...)`(**整次调用一份**,分批共享) |
| `ocr.py:274` | `return await self._run(...)` | 同款包住 `_run(...)` |
三处均在既有 `try` 之内、`_CallContext` 之后,故到期路径照走 `except PolyGatewayError → emit_terminal_once`(§7)。`StructuredMW._run_ladder`(`structured.py:67-98`)与 `_embed_batch`(`embedding.py:295`)**不得**新建 scope:同级重试/重问/分批共享同一期限,否则期限被轮数放大 N 倍即等于没有。
### 4.3 装配路径与 per-call 覆盖(实际签名)
| 层 | 签名变化 | 语义 |
| --- | --- | --- |
| 配置键 | `{SCOPE}__CALL_DEADLINE_S`,经 `_first` 读(**不用** `_require`,否则是破坏性配置变更) | 未设 = `None` = 不启用 |
| `GatewaySettings` | 末尾追加 `call_deadline_s: float \| None = None` + `_validate_call_deadline()` 进 `__post_init__`(见 `config.py:186-193`) | 有默认值,不扰动既有位置构造;校验覆盖 env/直接构造/`dataclasses.replace` 三条路 |
| 值域校验 | **全装配路径共用** `deadline.ensure_call_deadline`(§4.1):`GatewaySettings.__post_init__`、三个 client `__init__`、四个公开方法各调一次,实现只一份 | 拒收: `bool`(True 不得当 1 秒)、非数值类型、`NaN`、`inf`、`0`、负数 → `ValueError`(消息含来源)。**不与 `timeout_s` 耦合**:期限短于单次超时是合法选择 |
| 三个 client `__init__` | 追加 keyword-only `call_deadline_s: float \| None = None`,**入口即校** | 与 `now`/`sleep`/`rng` 同款注入位;全量注入是正式装配路,不得只靠 `GatewaySettings` 守门(否则 `inf` 静默失效、`NaN` 每次调用当场失败) |
| 三条 `from_settings` | 传 `settings.call_deadline_s`(embed/OCR 取 `settings.gateway.call_deadline_s`) | `from_env` 无签名变化(经 settings 透传) |
| 四个公开方法 | `chat`/`embed`/`recognize_text`/`parse_layout` 追加 keyword-only `call_deadline_s: float \| None = None` | `None` = **继承装配值**;正数 = 本次覆盖;**不提供"本次关闭"**(需要不同期限就装配两个 client;三态哨兵不值这个公共面复杂度) |
| 校验时点 | per-call 值在 `_CallContext` 创建**之前**、也在构造被包裹协程之前校验(与既有三项校验同列;OCR 两个入口经 `_call` 两级透传,与 `image` 校验同列) | 输入校验边界保持:非法期限抛裸 `ValueError`,不进统计边界、不写终态行、不遗留未 await 协程 |
### 4.4 新错误类型
```python
class CallDeadlineExceeded(PolyGatewayError):
"""调用方设定的整体期限到期;不是网关不可用、也不是源故障。"""
def __init__(self, *, scope: str, deadline_s: float) -> None:
super().__init__(f"{scope} 调用期限 {deadline_s}s 到期")
self.scope, self.deadline_s = scope, deadline_s
```
| 决策 | 取法 | 理由 |
| --- | --- | --- |
| 父类 | `PolyGatewayError` **直接**子类 | 不用 `TransientError`: 那是"退避后可重试、计熔断"的源级瞬时故障,下游按它无限外层重试只会把同一份期限再等一遍;不用 `GatewayUnavailableError`: 其消息硬编码"{scope} 网关暂时不可用"(`errors.py:156`),把调用方自选的期限报告成 scope 死亡,正是要避免的"用一个时钟冒充另一个"(BL4) |
| `scope` 字段 | **有** | 多 scope 部署时的诊断分组,免得下游从消息串里解析 |
| `retry_after_s` 字段 | **无** | 期限到期不含"何时可再试"的信息;给 `0.0` 会按 `errors.py` 既定语义指示下游**立刻重打仍饱和的渠道** |
| `SCOPE_REASONS` | **不新增值** | 它不是 `GatewayUnavailableError` 家族成员,与 `reason` 无关 |
| 四分类 | 不变 | 它是**调用方策略**的终止信号,不是四分类里的失败;文档须明写 |
| 导出 | 进 `__init__.py` 的 `__all__` | 下游要能 `except CallDeadlineExceeded` |
**醒目**: `except GatewayUnavailableError` / `except AllSourcesExhausted` 的存量代码**接不住**本异常——这是有意设计,且只在显式配置期限后才可能出现。CHANGELOG / wiki / README 必须以此措辞列出。
## 5. 时钟与失败模式的边界
### 5.1 两个时钟不混用
| 时钟 | 用途 | 纪律 |
| --- | --- | --- |
| 事件循环时钟(`loop.time()`,`asyncio.timeout` 内部) | 期限的**唯一**计时源 | 只传**相对时长** `deadline_s`;严禁把 `_CallContext._started`(注入 `now`)加上偏移当绝对截止时刻传进去 |
| 注入 `now`(`types.py:335`、三个 client) | `CallStats.total_latency_ms`、`StallClock`、退避 | 不读、不改;测试替换它不会影响期限判定,这一点必须在测试里明确 |
代价写实: 两者不同源,故 `total_latency_ms` 与 `deadline_s` 之间存在微小偏差(注入钟被伪造时可任意大)。这是**有意**的——统一它们要么强迫调用方注入 loop 钟,要么自建定时器,两者都比这点偏差贵。
### 5.2 四种"到期周边形态"必须分开
| 现象 | 判据 | 结果 |
| --- | --- | --- |
| 本层期限到期 | `except TimeoutError` 且 `cm.expired()` | `CallDeadlineExceeded`,终态行记 deadline |
| 内层自抛 `TimeoutError`(未到期) | `cm.expired()` 为假 | 原样上抛,不吞不改判(同 `streaming.py:52-58`);探针 D4 |
| **到期后清理路径自抛 `TimeoutError`** | `cm.expired()` 为真但异常对象**就是体内那一个**(身份比较) | 原样上抛该 `TimeoutError`,**不改标成 deadline**(探针 D2/D3;只看 `expired()` 的写法在此会误标)。代价: 该异常不是 `PolyGatewayError`,三个边界的 `except PolyGatewayError` 接不住→**无终态行**(与 1.3.5 已有的裸 `TimeoutError` 穿透行为同口径,非本版新增) |
| 外部取消 | `asyncio.timeout` 契约:不是本层计时器造成的取消 → `CancelledError` 原样上抛 | 走既有 `except asyncio.CancelledError` 分支,终态行记 `"cancelled"`;**不会**同时出现两条终态行(`claim_terminal()` 去重)。探针 D7/D8 实测: 外部取消**无论先于还是晚于到期**(含清理期到达)都胜出 |
| **到期窗口内体内先抛领域异常** | 计时器已触发、取消尚未投递到达时,体内先 `raise AllSourcesExhausted(...)` 等 | **领域异常原样逐层上抛,期限静默让位**;终态行记该领域异常而非 deadline。该窗口在重试循环真实存在(一次尝试刚结束与计时器同刻),复审 B 探针 E2/E8 已实测 |
故本设计只承诺"到期**通常**得 `CallDeadlineExceeded`",**不承诺 100%**;实现与测试均不得写成无条件断言。擦边竞态(计时器已触发但调用体已成功返回)不会遗留游离取消——这是 `asyncio.timeout` 的公共行为,库不自判形态、也不依赖任何 CPython 私有实现;跨版本保证由受支持 Python 矩阵上的行为测试提供(§10)。
### 5.3 期限治理的是"等待",不是返回时刻(必须写进 wiki,不得含糊)
asyncio 是**合作式取消**: 到期只是向任务投递一次取消,真正返回的时刻取决于在途 `await` 何时到达取消点,以及**清理路径**跑多久。已知会在期限之后继续跑的三段:
| 段 | 位置 | 性质 |
| --- | --- | --- |
| permit 结算与释放 | `retry.py:341` → `admission.py:46-60` | Redis 后端是两次网络往返;不受期限管辖 |
| 取消路径的 attempt 遥测行 | `retry.py:320-323` | 一次落库;丢了就丢了本次现场 |
| 终态遥测行 | 三个边界的 `emit_terminal_once` | **有意留在期限之外**:诊断行如果自己被期限切掉,期限到期这件事就没有台账 |
**量级不是毫秒级**: 复审 B 探针 E4 以上述真实形态(取消分支 0.2s 遥测 + `finally` 0.1s 结算)实测:期限 0.05s → 返回时刻 0.351s(**约 7 倍**);本轮独立探针 D6(清理 0.2s)复现同一形态: 0.251s(**5 倍**)。故对外措辞必须是"**返回时刻 = 期限 + 清理耗时**",而不是"最多 N 秒返回";清理耗时取决于 permit/遥测后端,可远超期限本身。§10 以量化断言把越限量变成可观测、可回归的量。
**反向代价(同等重要)**: 成功之后的旁路 IO **在期限之内**——`middleware/cache.py:199` 的 `_safe_set` 在 `call_next` 返回之后执行,`middleware/telemetry.py:734` 的缓存命中写同理。期限落在这两步 → 一个已完成、**已计费**的 `LLMResponse` 被丢弃,调用方只拿到 `CallDeadlineExceeded`。故 wiki 必须写明"期限到期**不等于**未产出、未计费";本版**不引入 `shield`** 去抢救它。
取舍是显式的: **宁可超出期限也要留下资源清理与诊断**,而不是引入 `shield`/后台任务去"抢救"(那会把取消语义弄脏,违反"取消可穿透"铁律)。若下游端口实现(自实现 transport / 遥测)在清理里长时间阻塞,期限的超出量就是那段阻塞时长——库不为第三方实现兜底。
## 6. 与既有算法的关系(明确不改的三件事)
### 6.1 F1:`Retry-After` 非有限值(纯 bug 修复)
`_parse_retry_after` 加判据: **解析成功但为无穷**(`inf`/`-inf`/`1e999`,判据 `math.isinf(seconds)`)时显式忽略并记一条 `warning`,按"服务端没给提示"处理,不伪造缺省值。`nan` 维持现状——它被既有的 `seconds > 0` 恒假拦下,**静默 `None` 且不告警**(§2),本版**不给它加告警、不改判据顺序**。
告警要有主语但不得回显不可信输入,故函数签名改为 `_parse_retry_after(raw: str | None, *, source_name: str) -> float | None`:`source_name` 是**必填 keyword-only 私有参数**(带前导下划线的模块内函数,不属公共面,无需 keyword 默认值兜底),唯一调用处 `_translate_429`(`transports/openai_compat.py:140`)传 `source_name=source.name`。warning 只写源名与判据词(如 `retry_after_not_finite`),**不拼接、不截断、不打印原始头字符串**。其余形态(HTTP-date、空串、负数、不可解析)**维持静默返回 `None`**--HTTP-date 是 RFC 7231 合法形态、空/负是常见噪声,逐次 warning 会在 429 风暴时把真缺陷的信号淡掉。不抛 `RequestRejectedError`:一个坏响应头不该把一次可重试的 429 判死。
### 6.2 有限大值的 `Retry-After` 不夹上限
审查 BL1 建议 `min(retry_after, backoff_max_s)`,**本设计不采纳**:夹小的直接后果是提前重打一个明确说了"3600 秒后再来"的饱和渠道,把服务端调度指令改写成库的猜测。有限大值的处置只有一条正路——调用方设期限,由 §4 的硬边界在到期时切断那次 sleep。未配期限即维持 1.3.5 语义(等满 `Retry-After`),这一残余必须在 wiki 明写。
### 6.3 取消路径结算修复(**已获批,本版第一个原子提交**)
现状(现读): `retry.py:281 actual = 0` → `:320` 取消分支 → `:341 finally` → `admission.py:46-60 settle_and_release(permit, actual)`;`settle` 算的是 `delta = actual - est`(`backends/memory/limiter.py:48-55`、`backends/redis/limiter.py:136-158`),故 `actual=0` = **把入场预扣的 TPM 整笔退还**。取消发生在 transport 在途时,上游可能已经计费——退款就是把已消耗的额度退回闸里。启用期限后库自己会常规性触发该路径,故先修后启用。
**精确结算矩阵**(以“这一刻库到底知道什么”为唯一判据;`est` 指 `source.effective_est_tokens()`):
| # | 取消落点(精确位置) | 库此时知道的事实 | `settle()` 取值 | 本版是否改变 |
| --- | --- | --- | --- | --- |
| S1 | 准入阶段(`admission.pick`:`try_enter` 异常、开路分支) | **确定未调用 transport** | `0`(全额退) | 否(既有行为即此) |
| S2 | 退避 `sleep` / 配额轮询 / 熝断等待 | 本轮未持 permit(上一轮已在 `finally` 结清) | 无 permit 可结 | 否 |
| S3 | `_attempt` 内、transport 在途(`retry.py:288` 的 await 未返回) | **端口已开始但用量未知** | `est`(delta==0,保留预扣) | **是**——原为 `0` |
| S4 | transport 已返回、`actual` 已算出后的任一 await(记账写回/逐次遥测) | **完整 usage 已知**(measured/estimated) | 保留已算出的真实 `actual`,**不得被取消分支覆盖回 `est`** | 否(现行为已正确,修复不得弄坏) |
| S5 | **已处理领域失败**分支内的 await(`record_failure`/`_emit`)中途取消 | 已知失败类型,结算决定已算出 | 该分支的决定值:瞬时 = `est`;`SourceDead` = **`0`**;`RequestRejected`/`ResultInvalid` = `0` | 是——决定移到该分支同步处理之后、紧贴首个 await 之前,故 `SourceDead` 的既有 `0` 在取消下**被保住**而不再退化成 `est` |
| S6 | OCR 任何位置(`ocr.py:449 settle_and_release(permit, 0)`) | **OCR 无 token 是事实**,不是“未知” | `0` | 否(事实即 0,不得改成 `est`) |
| S7 | embedding 与 chat 同构两处(`embedding.py:392-407` 取消分支) | 同 S3/S4 | 同 S3/S4 | **是**(与 chat 同口径同时改) |
| S8 | **未被任何 except 接住**的异常(`RuntimeError`、`KeyError` 等未分类逃逸) | 库对用量一无所知,且**不在本版批准范围** | `0`(与 1.3.5 逐字一致) | 否——本版**不**把"端口开始 = 可能已计费"推广到未分类异常 |
**实现形态**(实施时不得变形;人类只批准了"取消路径"这一条,故语义扩大**必须**被限制在取消分支内):
`actual` 初值**保持 `0` 不动**;另设一个**局部阶段变量** `settlement_known: bool = False`(纯函数内局部,不是新公共面、不进任何签名、不进配置)。置位规则只有两条——成功路径拿到用量(真实 usage 或 `usage_source == "unavailable"` 的 `est`)后置 `True`;三个**已处理领域失败**分支在**进入分支后的第一条语句**算出结算值并置 `True`。取消分支只在 `settlement_known` 仍为 `False` 时才赋 `actual = est`。
```python
actual = 0
settlement_known = False # 局部阶段变量: 该刻库是否已算出确定结算
# 成功: actual = 真实 usage 或 est → settlement_known = True
# RequestRejected / ResultInvalid: actual = 0; settlement_known = True(分支首句)
# SourceDead / Transient: actual = 0 if dead else est; settlement_known = True(分支首句)
except asyncio.CancelledError:
if not settlement_known: # 端口已开始、结算未定 → 保守保留预扣
actual = source.effective_est_tokens()
...
```
三条不变量(同级 `except CancelledError` 接不住其他 `except` 块内的取消,故必须在其首个 await 前先确定结算,而非只靠标志位):① **已确定的值一律不覆写**,包括真实 usage 恰为 `0`(源真返回 0 token 是事实,不是"未知");② "确定结算"的界桩按**当前代码里 failure 记账 await 的前后位置**划定——失败分支的结算决定被前移到 `record_failure` / `_emit` **之前**,故取消无论落在这两个 await 的哪一侧,拿到的都是该失败类型本来的值;③ 未被任何 `except` 接住的异常不经上述任一分支,`actual` 保持 `0` 原样传播(S8)。
**明确收窄**: 本版**不再**把 S5 泛化成"一切失败按 `est` 结算"。1.3.5 的 `SourceDead` 退全款(`0`)是**有意**的语义(源已判死,不该继续占额度),取消恰好落在它之后时必须保留那个 `0`;"端口开始 = 可能已计费"只是**取消且结算未定**这一格的兜底取值,不是全局通则。
这样只有取消路径的取值发生改变,其余四条既有路径与未分类异常路径逐字不变(验收矩阵 §10 "结算"批次逐条钉,并新增一条**防越界回归**)。
**保守结算的诚实边界**(不得写成“修对了”): `await transport.complete(...)` 返回前被取消,只能证明**端口协程已被进入**,不能证明 HTTP 字节已发出、更不能证明上游已计费。故 S3 是一个**保守选择**(宁可多扣不可凭空退款),不是事实性计量;它与既有的“瞬时失败按 `est` 结算”(`retry.py:337`)同一口径、同一理由。库**不新增任何 wire 事件协议**(如“transport 上报字节已发出”)来缩小这个不确定区——那是新公共端口面,且 httpx 层面也给不出可靠信号;不确定性写进文档而不是藏起来。
**不改且不被本版解决的相邻缺口**(列出以免被读成已修): ① `ResultInvalidError` 路径(如 `embedding.py:350-355` 维度不符、`transports/openai_compat.py:280-308` 响应形态异常)——响应真实返回过(已计费)但仍按 `0` 退全款;② `RequestRejectedError` 同理。两者与取消无关,属另一族记账语义变更,**未获批准即不动**,另行立 issue。
**共享状态不变**: `permit.release()`、`pacer.leave()`、`breaker.release_probe()`(探针归还)、`mark_progress` 全部保持原样且仍在 `finally`;本修复**只改 `settle()` 的入参取值**,不动限流 Lua、不动端口签名、不动幂等语义。
## 7. 遥测(无新增列,无新增 DDL)
| 行 | 到期时的取值 |
| --- | --- |
| attempt 行 | 被取消的那次仍记 `error="cancelled"`(`retry.py:320-323`)——它描述的是**那次尝试**的真实结局,保留 |
| 终态行 | 经既有 `emit_terminal_once` 写出;到期**通常** `error_type='CallDeadlineExceeded'`(例外见 §5.2 第 4 行:体内先抛领域异常时记该异常)、`error` 为其消息串(`telemetry.py:274-305` 既有取值逻辑,零改动),`logical_call_id`/`attempts`/`total_latency_ms` 照旧 |
| 计数 | 每逻辑调用至多一条终态行,由 `claim_terminal()` 保证;**不会**同时出现 cancelled 与 deadline 两条 |
## 8. 变更点清单(反 gold-plating)
| 类别 | 内容 |
| --- | --- |
| 新增文件 | `src/polygateway/deadline.py`(1 个校验函数 + 1 个包裹函数) |
| 改动文件 | `errors.py`(1 个类)、`__init__.py`(1 个导出)、`config.py`(1 个键 + 1 个 loader + 1 个守卫 + 1 个字段,守卫直调 `deadline.ensure_call_deadline`)、`client.py`/`embedding.py`/`ocr.py`(各 1 处包裹 + 构造参数 + 公开方法参数 + 入口校验 + `from_settings` 透传)、`middleware/retry.py` 与 `embedding.py` 的 `_attempt`(§6.3 结算矩阵:只改 `actual` 取值 + 1 个**局部**阶段变量 `settlement_known`)、`transports/openai_compat.py`(F1 一行判据 + warning + `source_name` 必填私有 kw 及其唯一调用处)、`pyproject.toml`(import-linter layers 新增 `polygateway.deadline` 一层) |
| 直接复用 | `_CallContext`、`emit_terminal_once`、`claim_terminal`、`asyncio.timeout` + `cm.expired()` 范式、`settle_and_release` 单一出口、既有 FakeClock/假 transport 测试设施、限流契约套件(Lua 不改) |
| **明确不做** | 不改 `backoff_delay`/`SourceAdmission._nap`/`StallClock`/429 分账/限流 Lua/端口签名;不改 `ResultInvalid`/`RequestRejected` 的结算口径;不加遥测列;不加 `shield`/后台任务;不加 429 次数键;不做 #24 |
## 9. 集中人类批准项(2026-09-10 全数获批)
| # | 决策 | 结果 | 不采纳的代价 |
| --- | --- | --- | --- |
| H1 | 采纳 §3 方案 A(单一 `asyncio.timeout` 硬边界),缺省 `None` | **已批** | B/C 只能给轮次粒度或多键相加的"伪期限" |
| H2 | `CallDeadlineExceeded` 为 `PolyGatewayError` 直接子类、无 `retry_after_s`、不进 `SCOPE_REASONS` | **已批** | 挂进 `GatewayUnavailableError` 会把调用方的期限报告成网关不可用 |
| H3 | 取消结算修复的位置 | **已批 (a′)**: 不再另立前置 issue,改为 **1.3.6 内的第一个原子提交**,口径按 §6.3 矩阵(S1-S8,含 S8 未分类异常维持 `0` 的收窄)逐格定死 | 选 (b) = 把已知记账缺口变成常规路径,正是本项目反复吃过的亏 |
| H4 | per-call 覆盖形态: §4.3 的"`None` 继承、无单次关闭" | **已批** | 三态哨兵扩大公共面;完全不给 per-call 则长短调用必须装两个 client |
| H5 | §6.2 不夹有限大 `Retry-After`(与审查 BL1 建议相反) | **已批**,残余写进 wiki | 夹小即提前重打饱和渠道 |
| H6 | §5.3/§11 的对外承诺形态: 期限治理**等待**,返回时刻 = 期限 + 清理耗时(实测可达 5-7 倍),且到期可能丢弃已计费成功 | **已批**,不引入 `shield` | 写成"最多 N 秒返回"会让下游上层超时被整片击穿 |
| H7 | issue #22 关闭判据 = "调用方**能配置**上限";#24 保持 open、**本版不实现** | **已批** | 见 §11 |
实施边界不得再扩: 本表之外的任何公共面变化(新配置键、新端口方法、新遥测列、其它记账口径变更)均属**未批准**,需停下来报。
## 10. 离线验收矩阵(实施时须先失败后通过;全部不触网、不付费)
| 批次 | 断言 |
| --- | --- |
| 未启用回归 | `call_deadline_s=None` 时三条链路行为逐字不变:现有 `tests/unit/test_retry.py`、`test_backpressure.py`、`test_embedding.py`、`test_ocr_client.py`、`test_client.py` 全绿(不改一行断言) |
| 期限命中 | 假 transport 真实 `await asyncio.sleep(0.3)`、`call_deadline_s=0.05` → 抛 `CallDeadlineExceeded`,`scope`/`deadline_s` 正确;计时范式沿用 `tests/unit/test_streaming.py` 的真实 loop 时钟 + 4-10× 余量(已验证稳定,不标 slow) |
| 覆盖面 | 分别令 ①退避 sleep(注入真 `asyncio.sleep`)②准入排队(配额满轮询)③结构化重问 ④embedding 多批 各自超期 → 均抛 `CallDeadlineExceeded`;embedding 断言 N 批**共享一份**期限(总时长不随批数放大) |
| 形态区分 | ①内层自抛 `TimeoutError`(假 transport 直抛)且未到期 → 原样上抛,不变成 deadline;②外部 `task.cancel()` → 仍抛 `CancelledError`,终态行 `error="cancelled"`;③擦边成功(transport 耗时略小于期限)→ 正常返回,无游离取消;④**到期窗口内体内先抛领域异常**(自旋构造确定性窗口)→ 上抛该领域异常、终态行记它,**不断言必为 deadline**;⑤**到期后清理自抛 `TimeoutError`**(假端口在取消分支里 `raise TimeoutError`)→ 原样上抛该 `TimeoutError`,**断言不是 `CallDeadlineExceeded`**(钉住身份比较机制;只看 `cm.expired()` 的写法在此变红) |
| 结算(§6.3 矩阵逐格) | S1 准入取消 → `tpm_used` 回到 0;S3 transport 在途取消 → `tpm_used == est`(**先失败后通过**的核心红绿);S4 取消落在 usage 已知之后 → `tpm_used == 真实 usage`(不被 `est` 覆盖);S6 OCR 取消 → `tpm_used == 0`;S5 取消落在 `SourceDead` 的 `record_failure` await 中途 → `tpm_used == 0`(**不得**变成 `est`);**S8 防越界回归**: 假 transport 抛 `RuntimeError`(未分类)→ `tpm_used == 0` 且异常原样上抛;成功/`SourceDead`/`RequestRejected`/`ResultInvalid` 四条既有路径结算值逐字不变;真实 usage 恰为 0 的成功 → `tpm_used == 0`;permit `release`/`pacer.leave`/`release_probe` 调用次数不变 |
| 终态遥测 | 到期恰好一条 `event_kind='terminal_failure'` 行,`error_type='CallDeadlineExceeded'`;被取消的 attempt 行仍为 `cancelled`;两者 `logical_call_id` 一致;列数不变 |
| 清理不可越过(含量化) | 到期时 permit 的 `settle`/`release` 与 pacer `leave` 仍被调用(假 permit 记账断言);`CancelledError` 未被吞;**量化断言**: 注入已知 sleep 的假 permit/假 emitter → 返回时刻 ≈ 期限 + 已知清理时长(可远大于期限本身) |
| 成功旁路被切 | 假缓存后端 `set` 慢于期限 → 抛 `CallDeadlineExceeded` 且断言上游 transport **已成功调用一次**(已计费成功被丢弃,§5.3),把该行为钉住而非留作偶然 |
| 注入钟无关 | 伪造注入 `now`(跳变 10^6 秒)不触发期限;反之期限触发时 `total_latency_ms` 仍来自注入钟 |
| 配置(全装配路径) | 键未设 → `None`;`0`/负/`nan`/`inf`/非数/`True`(bool 不得当 1 秒)→ `ValueError` 且消息含来源——**四条路各测一遍**: env、`GatewaySettings` 直接构造、`dataclasses.replace`、**三个 client `__init__` 直传**;per-call 非法值在构造协程前抛错且**无 "coroutine was never awaited" 警告**;`call_deadline_s < timeout_s` **不报错**(允许) |
| F1 | `Retry-After` 为 `inf`/`-inf`/`1e999` → 按无提示处理且各记一条 warning(**断言日志含源名、不含原始字符串**);`nan`/空/负/HTTP-date → 静默 `None` 且**无 warning**;`_parse_retry_after` 的 `source_name` 为必填 kw(漏传即 `TypeError`);有限正数仍取大;`insufficient_quota` 仍归 `SourceDead` |
## 11. issue 闭环判据
| issue | 判据 | 措辞纪律 |
| --- | --- | --- |
| #22 | **可关闭**: 调用方通过 `{SCOPE}__CALL_DEADLINE_S` 或 per-call 参数即可给一次调用设上限 | 措辞必须是"**期限治理的是等待,不是返回时刻**;返回时刻 = 期限 + 清理耗时(取决于 permit/遥测后端,实测可达数倍)",不得写成"最多等 N 秒"的硬保证;同时写明"到期不等于未产出、未计费"(§5.3)与"**不配置就保持 1.3.5 旧语义**"(纯 429 序列仍可能长时间等待、有限大 `Retry-After` 仍照睡)——三句均需出现在 CHANGELOG、wiki 与 issue 关闭说明的显要位置 |
| #24 | **保持 open、本版不实现**(未获批准),另行设计 | 期限只让长尾**更早失败**,与"更快成功"是两件事;任何文档不得把前者写成后者,也不得因本版而声称 #24 缓解 |
## 12. 待补证据与残余风险(诚实标注)
| 项 | 状态 |
| --- | --- |
| 取消是否让上游停止生成与计费 | 无一手证据 → §6.3 的 S3 只能是**保守选择**而非事实计量;不做任何"取消即省钱"或"已修准"的表述 |
| 保守结算的反向偏差 | S3 在“transport 确实未发出字节”时会**多扣** `est`(直到窗口滞后自然过期);与“凭空退款击穿网关”相比这是有意选定的方向(降级方向铁律),但必须写进 CHANGELOG |
| 未分类异常的结算 | `RuntimeError` 等未被四分类接住的异常仍按 `0` 退全款(S8)——人类只批准了取消路径,本版**不**将保守口径扩到它们;它们理论上同样可能发生在 transport 在途之后,属已知残留,需时另立 issue |
| `ResultInvalid`/`RequestRejected` 的退全款 | 本版**不改**(§6.3 末段);它们与取消无关,属未批准的另一族记账语义变更 |
| 跨 Python 版本的取消/超时语义 | 本设计只依赖 `asyncio.timeout` 的**公共行为**与异常对象身份比较,不引 CPython 私有实现作保证;保证由 §10"形态区分"在受支持 Python 矩阵上的测试提供(本轮探针仅覆盖 3.12.13) |
| 清理自抛 `TimeoutError` 时无终态行 | 该异常不是 `PolyGatewayError`,三个边界的 `except` 接不住(§5.2);与 1.3.5 已有的裸 `TimeoutError` 穿透同口径,本版不扩大也不修补 |
| #22 现场 46.7s/20min 数字 | 未复跑;本设计不依赖其数值,只依赖路径成立性(已由源码证明) |
| 第三方端口实现的清理耗时 | 不可控,直接构成期限超出量(§5.3) |
| 期限与 `stall_window_s` 的联合调参建议 | 本版不给推荐值:两者治理对象不同(调用方意志 vs scope 活性),给一个"经验公式"就是把两个预算再次绑死 |
@@ -0,0 +1,189 @@
# 长尾对冲请求(issue #24)设计
- 状态: **已批准**(2026-09-10 人类批准 §9 全部批准项 H1–H7,含增补 H8:`CallStats` 增 `generation_ms`/`hedge_won` 裸生成时间字段);本文件只做设计与权衡,不含实现
- 基线: main `166b286` / 1.3.6(已发布,含 `call_deadline_s` 与取消结算修复)
- 输入: issue #24 原文(非流式挂起后正常 200:20 次里 5 次超 60s、中位 15.1s、真实负载 13% 调用吃掉 71% 模型总时间、慢调用输出中位 186 token——在等不在生成、90–96s 窄峰疑似源侧固定机制)
- 关联: `designs/2026-09-09-136-call-budgets-design.md`(期限与取消结算,本设计直接站在其 S3 格上);ARCH §6.4 取消语义、§7.2 重试、§7.3 限流;`designs/2026-08-06-issue8-stall-budget-design.md`
## 1. 目标与非目标
| 项 | 内容 |
| --- | --- |
| 目标 1 | 非流式请求挂起超过阈值时,并发向**另一个等价源**再发一次,先回者赢,输家取消——把 p99 从"挂起时长"压到"阈值 + 健康源耗时" |
| 目标 2 | 流式请求以 **TTFT 未至**为触发判据(不误杀慢生成);非流式无 TTFT 可观测,用总时长阈值 |
| 目标 3 | 默认关闭;开启后的一切行为(配额、熔断、遥测、结算)可观测、可对账 |
| 非目标 A | embedding / OCR 不做对冲(无 TTFT 概念、issue 未涉、无配置面) |
| 非目标 B | 不做滚动分位数触发(`AFTER_PERCENTILE`);不做同源对冲;不加遥测新列(默认档) |
| 非目标 C | 不改 `call_deadline_s`/`deadline.py`/限流 Lua/429 分账;不改既有四分类 |
## 2. 现状核实(现读 1.3.6 源码,不引用旧报告)
| 事实 | 证据 | 对本设计的意义 |
| --- | --- | --- |
| 非流式路径是单 JSON 响应,**仅 total 超时**,无中途进度信号 | `transports/openai_compat.py:646-654`(`_complete_once` docstring 原文)、`:684 ttft_ms=None` | 非流式的对冲触发**物理上只有总时长阈值**一种;"挂起 vs 慢生成"在非流式不可分,只能靠阈值取值与成本上限控制误对冲 |
| 流式首 token 观测点已存在 | `openai_compat.py:569-575`(`ttft_ms` 首次赋值处) | TTFT 事件信号只需在该点 `event.set()`,探测成本近零 |
| `Transport.complete` 是公共端口签名,库内唯一实现 | `ports.py:51-60` | 加首 token 事件参数 = **公共端口签名变更**,必须进批准项(H2) |
| 每次尝试 = 选源 → 熔断门 → 限流 permit → transport,全在 `_attempt` 内 | `middleware/retry.py:276-353`;准入编排 `middleware/admission.py:171-213`(`pick`) | 对冲 = **并发跑第二次 `_attempt`**,配额/熔断/结算/pacer 全部复用,无需发明第二套准入 |
| 取消路径结算: `settlement_known=False` 时保留预扣 est | `retry.py:327-331`(1.3.6 S3 格);finally 结算 `:352-353` → `admission.py:46-60` | 对冲输家走取消路径,**结算语义现成**:额外成本上限 = 一份 est 预扣滞留 |
| 取消的 attempt 行记 `error="cancelled"` 后穿透 | `retry.py:332-334` | 输家遥测只需换一个区分字符串,零新列(§4.5) |
| `_CallContext` 承诺"每调用一个实例的**单任务**对象,计数无需锁" | `types.py:321-337`;`register_attempt` `:339-345`、`claim_terminal` `:354-360` 均为无 await 同步方法 | 对冲引入第二个并发任务,该 docstring 承诺须修订;同步方法在事件循环内天然任务安全(无 await 间隙),机制零改动 |
| 成功响应在**返回前**冻结 `call_stats` 快照 | `client.py:442`(`dataclasses.replace(response, call_stats=context.snapshot())`) | 输家取消收口必须**先于**快照,否则 `attempts` 漏计输家(§4.5) |
| 期限包整棵树,缺省 None 不进上下文 | `deadline.py:64-86`;接入点 `client.py:419-421`;配置 `config.py:190`、`:695-715` | deadline 与 hedge 正交组合,`deadline.py` 零改动(§6) |
| 429 免重试预算且耗时退 stall 账 | `retry.py:158-164`、`:250-257` | 对冲轮内某任务 429 的免预算语义沿用 attempt 级既有机制(§4.6) |
| 配置键两段/三段式天然跳过 `_load_sources`;保留段防撞名 | `config.py:401-407`(len==4 判定)、`:57`(`_RESERVED_SEGMENTS`) | 新键 `{SCOPE}__HEDGE__AFTER_S` 为 3 段,天然不被当源字段;`HEDGE` 须加进保留段(§5) |
## 3. 备选方案与权衡
| 维度 | **方案 A:并发对冲(推荐)** | 方案 B:取消式投机重试 | 方案 C:仅流式对冲,非流式只靠 deadline |
| --- | --- | --- | --- |
| 做法 | 阈值到 → 并发向异源发第二次 `_attempt`,`asyncio.wait(FIRST_COMPLETED)`,赢家返回、输家 `cancel()` 并 await 收口 | 阈值到 → 取消在途 attempt,按可重试失败走既有换源重试循环 | 只对 `stream=True` 做 TTFT 对冲;非流式维持 1.3.6 现状(期限切长尾) |
| 挂起请求的信号处理 | 输家只是"被取消",**不喂熔断/健康分**——挂起的请求最终正常 200,记 failure 是错误信号(源没坏,是这一跳排队) | 必须新造一类"挂起失败":复用 Transient 会把未死源喂进熔断失败计数(`retry.py:340/346-348`),污染熔断与健康分;新造免预算类别 = 又一类四分类外特例 | 同 A(但只覆盖流式) |
| 重试预算 | 对冲不消耗 `max_attempts`——它是"一次尝试的加速形态" | 消耗预算(3 次挂起即 `AllSourcesExhausted`),除非新造免预算类 | 同 A |
| 尾部赢面 | 原请求"后发先至"时仍可用其成果;尾部的尾部 = min(两路) | 原请求成果恒被丢弃;延迟恒 = 阈值 + 重试耗时 | 流式同 A;非流式尾部 = 期限(更晚失败,不是更快成功) |
| 成本 | 对冲窗口内两路并发,输家可能被上游计费 + 一份 est 预扣滞留 | 取消更早(阈值即取消),已计费浪费**更少** | 最低(覆盖面也最小) |
| 实现量 | 大:对冲编排 + 端口加参 + 并发收口 + 遥测区分 | 约为 A 的 1/3:阈值计时器 + 取消 + 失败归类 | 中:同 A 但免非流式分支 |
| 解决 issue 现场 | 是(issue 复现即非流式) | 是 | **否**——issue 的现场就是非流式,等于没解决 |
**关键判断**: B 的性价比看似更高(issue 数据显示挂起峰在 90s+,原请求几乎不可能后发先至),但它要回答一个 A 不用回答的问题——"挂起中的源该不该记失败"。记,则熔断/健康分被一次排队事件污染(双峰窄峰指向源侧固定机制,不是源死亡);不记,则要在四分类外新造语义。A 让输家落进 1.3.6 已有的取消路径,**零新分类语义**,且对冲拿不到配额时自然静默(饱和期不添乱)。C 不解决原问题,仅列为范围收缩的退路。
**推荐 A**;B 作为"预算敏感且接受熔断语义代价"的降级备选保留在批准项(H1)中由人类定夺。
## 4. 方案 A 的具体形态
### 4.1 触发条件(设计问题 1)
| 调用形态 | 触发判据 | 机制 |
| --- | --- | --- |
| 流式 | 已过 `hedge_after_s` **且首 token 事件未置位** | `Transport.complete` 加 keyword-only 参数 `first_token_event: asyncio.Event \| None`(必填,不设默认值,与端口既有约定同款);`OpenAICompatTransport` 在 `openai_compat.py:573-575` 首 token 处 `set()`。阈值计时器 = 等待该事件,超时即触发 |
| 非流式 | 已过 `hedge_after_s`(纯总时长阈值) | `_complete_once` 物理上无中途信号(`openai_compat.py:646-654`),事件**永不置位**直到完成——同一套"等事件超时"机制自然退化为时间阈值,**零分支** |
| 分位数触发 | **v1 不做** | 滚动分位数需要 per-source 状态窗口,跨进程部署还得进 Redis;issue 的双峰形态(主峰 0–10s vs 挂起峰 90s+)用绝对阈值区分度已足够。保留为未来扩展 |
- "非流式不对冲只做 deadline"已被方案 C 覆盖并否决(不解决 issue 现场);但**配置层面允许只对流式生效**——`stream=False` 的调用方若不接受误对冲成本,可不配阈值。
- 误对冲的代价有界:最多 `hedge_max_extra` 次额外请求/逻辑调用,输家记账见 §4.4。
- 时钟纪律同 deadline(136 设计 §5.1):只用**相对时长 + 事件循环钟**,不读注入 `now`——测试伪造注入钟跳变不得触发对冲,验收矩阵钉住。
### 4.2 对冲目标 = 异源(设计问题 2)
| 决策 | 取法 | 理由 |
| --- | --- | --- |
| 同源 or 异源 | **异源,且仅异源** | issue 观测的 90–96s 固定窗口窄峰指向源侧机制;同源对冲 = 给同一队列再排一个号,徒增成本 |
| 等价源定义 | 同 scope 内 `SourceAdmission.pick` 正常排序选出的下一个候选——等价性由 **scope 语义**承诺(同 scope 源本就可互换,同 model 集合是常态),对冲层不发明新的等价概念 | 复用既有选源排序、冷却备忘、调用内降权(`admission.py:63-127`),不新建"等价类"配置维度 |
| 排除当前源 | `pick` 加**私有**排除参数(如 `exclude: frozenset[str]`);对冲任务以在途源名为排除集 | 改动收敛在 middleware 内部,不碰公共端口 |
| 无候选可用 | 单源 scope / 其余源全冷却、开路、配额满 → **放弃本次对冲**,继续等原请求 | 对冲是优化不是权利;单源 scope 配了阈值 = 装配期 warning、运行期自然静默(§5) |
| 同源对冲开关 | 不做(YAGNI) | 配置面少一个维度;真出现"源内分片排队"形态再立 issue |
### 4.3 准入不独立:对冲走完整准入(设计问题 3)
对冲请求**照常走** QuotaGate + BreakerGate + pacer + 冷却备忘(`admission.py:171-213` 的完整 `pick` 路径),**不给旁路**:
| 情形 | 行为 | 对齐 |
| --- | --- | --- |
| 配额满 / 被熔断 / pacer 超限 | 放弃本次对冲,原请求继续等(不抛错、不排队硬等) | 对冲若绕闸,源挂起风暴时并发翻倍打进正在排队的网关——正是限流铁律要防的击穿;拿不到配额时自然静默,饱和期不添乱 |
| 限流/熔断后端不可用 | `try_acquire`/`try_enter` 抛 `GovernanceBackendError`,照常冒泡 | 铁律"后端不可用 → 报错而非放行",对冲分支不新增降级面 |
| permit 持有 | 赢家输家各持各的 permit,各自 `finally` 结算释放(`retry.py:352-353`) | 与两个独立并发调用完全同构,限流契约零改动 |
### 4.4 成本与取消记账(设计问题 4)
| 角色 | 结算 | 依据 |
| --- | --- | --- |
| 赢家(先成功) | 正常成功路径:`settle(实际 usage)`;`usage_source="unavailable"` 时按 est | `retry.py:300-308` 既有分支,零改动 |
| 输家(被取消) | 落 1.3.6 取消 S3 格:transport 在途、结算未定 → `settle(est)`(**保留预扣,不退款**) | `retry.py:327-331`;上游可能已对输家计费,est 保留是保守下限——与期限到期同口径,文档明写"对冲掉的那次可能已计费" |
| 输家(取消前已真失败) | 走既有失败分支结算(dead=0/瞬时=est) | `retry.py:346-347`,取消落点决定取值,S5 机制已覆盖 |
| 对冲轮内 429 | attempt 级免预算 + stall 退还照既有机制 | `retry.py:158-164` |
**计费对账口径**:一次逻辑调用对冲一次的最大额外成本 = 一份 est 预扣滞留(窗口过期自动释放)+ 输家已被上游计费的不可观测部分。下游要能算出"对冲浪费多少钱"——靠 §4.5 的遥测区分,而不是新记账通道。
### 4.5 并发安全与遥测区分(设计问题 5)
| 关注点 | 设计 |
| --- | --- |
| `_CallContext` 共享 | 两个对冲任务共享同一个 context(同一逻辑调用)。`register_attempt`/`claim_terminal`/`snapshot` 均为**无 await 同步方法**(`types.py:339-360`),事件循环内任务并发调用天然安全,机制零改动;但 `types.py:321-337` docstring 的"单任务对象"承诺须修订为"单逻辑调用、可多任务并发登记"。增补 H8 后 context 再持 `_hedges`/`_generation_ms`/`_hedge_won` 三个计数与 `register_hedge`/`record_generation` 两个同步方法,任务安全性与 `register_attempt` 同款 |
| 逻辑调用 ID | 不变:两任务共享 `logical_call_id`;各 attempt 独立 `call_id`(uuid4,`retry.py:279`) |
| 快照时点 | 赢家产生 → 输家 `cancel()` 并 **await 收口完毕**(输家 finally 的结算/遥测跑完)→ 才允许 `client.py:442` 的快照返回。`attempts` 因此恒含输家(=2),`total_latency_ms` 含输家清理耗时——与 deadline"返回时刻 = 期限 + 清理耗时"同口径 |
| 任务泄漏 | 编排用 `asyncio.wait(FIRST_COMPLETED)` + 显式收口;取消优先铁律不变:外部取消到达时两任务都被取消并穿透,不 shield、不留后台任务(ARCH §6.4) |
| 遥测行区分(默认档,零新列零 DDL) | 输家 attempt 行 `error="hedge_cancelled"`(与既有 `"cancelled"` 同通道,`retry.py:327-334` 同款字符串);赢家 attempt 行照常;`CallStats` **只增**三字段(`types.py:296-318` 既有快照对象,经 `LLMResponse.call_stats` 既有通道带出,`types.py:425`;三字段全带默认值,1.3.6 及以前构造的 `CallStats(...)` 位置调用不炸):`hedges: int = 0`(本次调用**实际并发发出**的对冲路数;触发但准入失败静默不计)、`generation_ms: int = 0`(裸生成时间,口径见下行)、`hedge_won: bool = False`(赢家是否对冲路) |
| `generation_ms` 口径(增补 H8) | **赢家那次 transport 调用的墙钟时长**(HTTP 发出到响应收完):chat/对冲 = 赢家那次;无对冲 = 成功那次 attempt;结构化重问 = 最后一轮(覆盖语义,每轮成功覆写);embedding = 各批 transport 时长之和(累加语义);OCR = 单次;缓存命中 = 0(未产生 transport 调用,0 是实测而非"未知")。**排除** admission 排队/backoff/对冲触发前等待/清理遥测;计时点收敛在三条链路 `_attempt` 的 transport 调用两侧,用该链路既有注入钟(与 `total_latency_ms` 同钟,差值才有意义);对冲编排裁定赢家后才写入 context,输家(含两路同时完成的竞速落选者)的值一律丢弃 |
| 对前端有用的对冲参数(增补 H8 取舍) | **纳入** `hedges` + `hedge_won` + `generation_ms`(经 CallStats 既有通道带出,零遥测新列);**不纳入**每路 attempt 分别耗时/输家身份——那是运维诊断面,遥测 DB attempt 行已有 `hedge_cancelled` 标签与同 `logical_call_id` 可 join 还原,不重复进公开响应。`generation_ms` 与 `total_latency_ms` 的**差值即波动开销**(等待/退避/准入/对冲触发前耗损),前端可直接展示"在等不在生成" |
| `hedge_cancelled` 标签机制 | 编排在 `cancel()` **之前**给输家任务置位标记(如 `task._polygateway_hedge_loser = True`);`_attempt` 的 CancelledError 分支读标记选 `"hedge_cancelled"`/`"cancelled"`。外部取消与对冲取消竞速时可能误贴——两任务同消、记账方向一致(est 保留),标签误贴不造成结算或熔断错误,属可接受并明写 |
| 遥测行区分(备选调) | attempt 表加 `hedge_role` 列(`NULL/'primary'/'hedge'`,PG/SQLite 各一次 DDL)——遥测列变更代价有 issue #12/#13/#15 教训,v1 不推荐;列进批准项(H3)由人类定夺 |
| 熔断/健康信号 | 输家取消**不喂**失败、赢家照常记成功——挂起不是源死亡证据(§3 关键判断) |
### 4.6 编排形态与失败汇合
- 对冲轮 = 一次"超级尝试":首个成功即本轮结果;**两任务都失败**才进既有重试循环,且 `fails += 1` 只计一次(对冲是加速形态,不是两次独立尝试;429 的免预算/refund 仍在 attempt 级生效)。
- 原 attempt 先失败、对冲在途 → 直接等对冲结果,不重试;对冲先失败、原 attempt 在途 → 继续等原 attempt(等价于未触发对冲)。
- `retry` 循环骨架(`retry.py:228-263`)、退避、stall 判定全部不变;变化收敛在"单轮尝试的内部从单任务变任务组"。
## 5. 配置面(设计问题 6;默认必须关闭)
| 键 | 值域 | 缺省 | 说明 |
| --- | --- | --- | --- |
| `{SCOPE}__HEDGE__AFTER_S` | 有限正数秒,复用 `ensure_call_deadline` 同款值域校验(`deadline.py:20-42`) | **未设 = 关闭** | 3 段键天然跳过 `_load_sources`(`config.py:401-407`);`HEDGE` 加进 `_RESERVED_SEGMENTS`(`config.py:57`)防 provider 段撞名 |
| `{SCOPE}__HEDGE__MAX_EXTRA` | int ∈ [1,3] | 1 | 每次逻辑调用最多并发对冲几路;>1 仅对冲再挂起时梯次追加 |
装配路径与期限同款(136 设计 §4.3 形态):`GatewaySettings` 末尾追加两字段 + `__post_init__` 新守卫(`config.py:190-201` 同列);`GatewayClient.__init__` keyword-only 参数**入口即校**(`client.py:239-241` 同列);`from_settings` 透传,`from_env` 无签名变化。
| 守卫 | 判定 | 理由 |
| --- | --- | --- |
| `hedge_after_s ≥ min(源 timeout_s)` | `ValueError` | 对冲永不可能触发,配置即错误(与 `_validate_probe` 同款装配期炸掉哲学,`config.py:346-355`) |
| `hedge_after_s ≥ min(ttft_timeout_s)`(仅设有该键的源) | 装配期 **warning** | 流式档挂起已被 TTFT 看门狗先行切断(`openai_compat.py:561-566`),对冲形同虚设;非流式仍有效,故不升 ValueError(与 `stall_window_s ≥ max(ttft_timeout_s)` 同型交叉守卫先例,`config.py:336-344`) |
| 单源 scope 设了阈值 | 装配期 **warning**,允许 | 源集合可运行期之外的配置演进;运行期拿不到候选自然静默(§4.2) |
| `hedge_after_s ≥ call_deadline_s`(两者皆设) | `ValueError` | 期限先于对冲触发,对冲形同虚设(§6) |
| chat() per-call 覆盖参数 | **不提供** | 对冲阈值是源/渠道特性,不是任务特性(期限有 per-call 是因为任务耐心不同);需要不同阈值就装配两个 client |
## 6. 与 `call_deadline_s` 的关系(设计问题 7)
| 维度 | hedge | deadline |
| --- | --- | --- |
| 语义 | **提前换路**:提高 deadline 内拿到结果的概率 | **最终保险**:超过耐心即终止(治理等待) |
| 层级 | RetryMW 单轮尝试内部 | 公开边界包整棵树(`client.py:419-421`),对冲编排在树内,`deadline.py` 零改动 |
| 独立配置 | 可只配 hedge(无期限) | 可只配 deadline(1.3.6 现状) |
| 组合 | `hedge_after_s < call_deadline_s`(装配守卫强制);典型:`timeout_s=300, hedge_after_s=8, call_deadline_s=120` | 输家取消的清理耗时不受期限管辖,沿用"返回时刻 = 期限 + 清理耗时"措辞(136 设计 §5.3);**per-call 覆盖** `chat(call_deadline_s=X)` 使 X < hedge_after_s 时,该次调用对冲不触发(deadline 先切整棵树),属合法语义不告警——装配守卫只管默认值,per-call 是调用方的当次选择 |
两者回答不同问题:deadline 让长尾**更早失败**,hedge 让调用**更快成功**——文档不得混写(136 设计 §11 已立此措辞纪律)。
## 7. 变更点清单(反 gold-plating;实施前置零)
| 类别 | 内容 |
| --- | --- |
| 改动 | `middleware/retry.py`(单轮尝试 → 任务组编排 + 对冲计时 + 输家 `hedge_cancelled` 遥测 + `_attempt` transport 级计时点);`middleware/admission.py`(`pick` 加私有排除参数);`ports.py` + `transports/openai_compat.py`(`complete` 加 `first_token_event` 必填 kw,流式首 token 处置位);`config.py`(两键 + loader + 两守卫 + 保留段);`types.py`(`CallStats` 增三字段 `hedges`/`generation_ms`/`hedge_won` + `_CallContext` docstring 修订与计数方法);`client.py`(构造参数 + 透传);`embedding.py`/`ocr.py`(`_attempt` 加 transport 级计时点——只计时不对冲,非目标 A 不变) |
| 新增文件 | 无(编排收敛在 retry.py;若超 150 行可拆 `middleware/hedge.py`,实施期定) |
| 直接复用 | 取消结算 S3 格、`settle_and_release` 单一出口、准入全链路、`asyncio.timeout` 范式、假 transport/FakeClock 测试设施、限流契约套件(Lua 不改) |
| 明确不做 | 不改四分类/熔断语义/429 分账/限流 Lua/`deadline.py`;不加遥测列(默认档);不做 embedding/OCR/分位数/同源对冲/per-call 参数;不引入 shield/后台任务 |
## 8. 测试策略(设计问题 8:事件驱动,不 sleep 撞窗口)
| 原则 | 做法 |
| --- | --- |
| 事件驱动假 transport | 两个 `asyncio.Event`(`first_token`/`complete`)精确控制 TTFT 与完成时刻;触发判定 = "事件未置位且计时器到期",从不真睡出长尾 |
| 真实 loop 钟 + 余量 | 对冲阈值取 0.05s 级、断言容差 4–10×(`tests/unit/test_streaming.py` 既有范式,136 设计 §10 已验证稳定,不标 slow) |
| 先失败后通过 | 同一挂起场景:无对冲 → 总时长 = 挂起时长(红);开启 → 总时长 ≈ 阈值 + 快源耗时(绿) |
| 注入钟纪律 | 伪造注入 `now` 跳变 10^6 秒不得触发对冲(对冲计时只用 loop 相对时长) |
验收矩阵(离线、不触网):① 触发两形态(流式 TTFT 未至触发/已至不触发;非流式纯时间触发);② 异源排除(断言第二请求落在另一源;无候选静默);③ 准入失败静默(配额满 → 不对冲,原请求照等);④ 赢输记账(赢家 settle 实际 usage、输家 settle est、`tpm_used` 断言);⑤ 并发安全(`attempts==2`、终态行恰 1 条、`logical_call_id` 一致、无任务泄漏告警);⑥ 默认关闭回归(现有全套件不改一行断言全绿);⑦ 外部取消穿透(两任务同消、`CancelledError` 上抛);⑧ 与 deadline 组合(期限切断含对冲的整棵树);⑨ 配置守卫四路(env/直接构造/replace/client 直传);⑩ 裸生成时间断言(增补 H8):**赢家计时不含等待**(对冲赢家的 `generation_ms` ≈ 赢家路 transport 时长,不含触发前等待/admission/backoff);**对冲赢家取快者**(对冲路赢 → `hedge_won=True` 且为对冲路时长;原路后发先至 → `hedge_won=False` 且为原路时长);**embedding 为批次和**(N 批各自 transport 时长累加);**缓存命中为 0**(第二次同 key 调用 `generation_ms == 0` 且 `attempts == 0`)。
## 9. 集中人类批准项
**状态: H1–H7 与增补 H8 全部已于 2026-09-10 获人类批准**(H1 取方案 A;H3 取零新列档;H5 取"v1 只允许 1")。
| # | 决策 | 推荐 | 备选代价 |
| --- | --- | --- | --- |
| H1 | 方案选型 | **A(并发对冲)** | B 省 2/3 实现量,但须新造"挂起失败"语义且污染或不污染熔断二选一;C 不解决 issue 现场 |
| H2 | `Transport.complete` 加 `first_token_event` 必填 kw(公共端口签名变更) | 批准 | 不加则流式只能用纯时间阈值,误对冲慢生成(issue 明示的反面) |
| H3 | 遥测区分档位 | 零新列(`hedge_cancelled` 字符串 + `CallStats` 三字段,含 H8) | `hedge_role` 列更规整但要 PG/SQLite 双 DDL + 迁移纪律 |
| H4 | 配置键名/值域/守卫(§5 全表,含交叉守卫 ValueError) | 按 §5 | 交叉守卫降为 warning 则错配静默 |
| H5 | `hedge_max_extra > 1` 的梯次对冲 | v1 只允许 1(键存在但上限 1 也接受) | 直接放开到 3 省一次版本,但多路对冲洗掉信号 |
| H6 | 两败计一次重试预算 | 批准 | 计两次会让对冲调用更快耗尽预算,语义说不过去 |
| H7 | embedding/OCR/分位数/同源对冲/per-call 参数全部不进本版 | 批准 | 任一纳入都是公共面扩大,需单独论证 |
| H8(增补) | `CallStats` 再增 `generation_ms`(裸生成时间,口径见 §4.5)与 `hedge_won` 两字段;retry/embedding/ocr 三条链路 `_attempt` 加 transport 级计时点 | 批准(2026-09-10,随 H1–H7 同日) | 不加则前端拿不到"在等不在生成"的量化口径,issue #24 的现场观测(13% 调用吃掉 71% 模型总时间)无法在产品面复现;每路 attempt 分别耗时与输家身份走遥测 DB join 还原,不进公开响应 |
## 10. 残余风险(诚实标注)
| 项 | 状态 |
| --- | --- |
| 输家取消能否止住上游计费 | 无一手证据(与 136 设计 §12 同款):est 保留只是闸内保守记账,**不是**上游真实计费的计量;文档只写"可能已计费",不写"对冲浪费上限 = est" |
| 非流式误对冲慢生成 | 物理不可分(无中途信号);只能靠阈值取值(建议 > 源 p50 数倍)与 `hedge_max_extra` 上限控制;分位数触发是未来缓解 |
| 对冲流量放大 | 开启后挂起窗口内 in-flight 翻倍;准入全走闸意味着饱和期自然静默,但**配置者须理解**对冲 = 用配额换延迟 |
| "挂起不喂熔断"的反向代价 | 一个持续挂起的源不会因对冲输家而被熔断标记;源级淘汰仍靠既有失败/超时路径——这是有意选择(§3),但运维上"挂起率"只能靠 `hedge_cancelled` 遥测行统计 |
| 两任务共享 `_CallContext` 的承诺修订 | docstring 级变更;若未来给 context 加带 await 的方法,须重审任务安全 |
| 多路对冲(H5 若放开) | 信号冲刷与成本上界均未论证,v1 不碰 |
@@ -0,0 +1,232 @@
# 流式中段沉默:计量口径与对冲(已批准设计)
- 状态: **已批准 + 独立审查补遗已并入(2026-09-11,`reviews/2026-09-11-streaming-stall-design-review.md`,B1-B3/N1-N9 全处置,见 §11)**;本文件只做设计,不含实现
- 基线: main `a28d944` / 1.3.7(已发布,含单路并发对冲与 `CallStats.generation_ms/hedges/hedge_won`)
- 输入: `findings/2026-09-11-streaming-stall-hedging-research.md`(业界范式与来源,下称"调研",引用处标注 §x.y);下游 Terminal-Bench 实测 4407 次成功调用遥测(需求来源);1.3.7 设计 `designs/2026-09-10-24-hedged-requests-design.md`;远端 issue #27(P1)/#28(P2)
- 关联: ARCH §6.4 取消语义、§7.2 重试;既有流式看门狗 `streaming.py`;1.3.7 对冲机制(本设计直接站在其编排与记账语义上)
## 1. 背景与问题陈述
两个问题都是**口径/能力缺口,不是实现 bug**:1.3.7 行为与其设计文档(§4.5 H8、对冲触发判据)完全一致,但下游实测暴露了设计时未覆盖的病态形态。
### P1:中段沉默计入 `generation_ms`,"裸生成时间"名不副实(issue #27)
`CallStats.generation_ms` 的口径是赢家 transport 调用的完整墙钟:`middleware/retry.py:332` `gen_started` 紧贴 `transport.complete()` 调用前,`:346` sink 在返回后立即记录。对流式,`complete()` 返回 = 整个流读完,故中段沉默全部计入——这是 1.3.7 设计明写的口径,但"裸生成时间"这个名字误导了下游把它当活跃生成时间用。
下游实测(Terminal-Bench,4407 次成功调用):
| 指标 | 数值 | 含义 |
| --- | --- | --- |
| ttft 中位 | 1.63s(仅 3 次 >20s) | 首 token 几乎从不慢 |
| 慢调用 `generation_ms` 中位 | 69.4s | 墙钟长尾严重 |
| 其中 `max_inter_token_ms` 中位 | 54.9s | 长尾的主体是**一次中段沉默** |
| 沉默占全部模型时间 | 71.3% | "在等不在生成"是主要成本 |
| 沉默与输出/输入量相关性 | r = 0.05 / −0.06 | 沉默与生成量无关 |
| reasoning_tokens 上报次数 | 129 / 4407 | 沉默**不是**模型在思考,是渠道/服务端排队(调研 §A4:prefill/decode 互踩是结构性现象) |
结论:下游需要一个"扣除沉默段的活跃生成时间"口径,且带归因(沉默几次、共多长),不是只给一个净数(Gregg off-CPU 分解原则,调研 §B2)。
### P2:中段沉默无对冲,现有两道闸门都接不住(issue #28)
| 既有机制 | 证据 | 为何接不住中段沉默 |
| --- | --- | --- |
| 首 token 对冲 `HEDGE__AFTER_S` | `middleware/retry.py:542-562` `_past_hedge_window` 返回 `not primary.done() and not first_token.is_set()`;非对冲模式 `__call__` 传 `first_token_event=None` 根本不观测 | 首 token 到达后对冲窗**永不打开**;实测 ttft 中位 1.63s,`AFTER_S=20` 对流式调用恒不触发 |
| 看门狗 `INTER_TOKEN_TIMEOUT_S` | `streaming.py`(budget = ttft_s 首 chunk / inter_token_s 后续);`openai_compat.py:573` `inter_token_s = source.inter_token_timeout_s or source.timeout_s`;配置 `config.py:46` | 能掐中段沉默,但**掐掉即丢弃全部已生成内容**、从头重试;重试可能再卡;每步成本 = 一次完整重新生成 |
调研结论(§A3):业界对中段沉默的落地形态收敛为"间隔看门狗 → 抛错 → 重试",**"中段沉默触发并发副本"在任何主流网关/库中均无公开先例**——本设计的 P2 方案 2B 属超业界现状能力,须诚实标注。
## 2. 业界范式摘要(压缩自调研,不重查网络)
| 范式 | 出处(调研节) | 对本设计的对应 |
| --- | --- | --- |
| 中段沉默对冲**无公开先例**;主流 = 看门狗 → Transient → 重试 | 调研 §A3(LangChain `stream_chunk_timeout` 默认 120s 已合并;Codex CLI 静默 300s 整路重试;LiteLLM 提案默认关) | P2 的 2A = 业界基线;2B 若做须自证语义 |
| 沉默扣除唯一先例 pi-tps:gap ≥500ms 判 stall,**减所有超阈值段**,连续停顿合并一次,样本不足返回 null | 调研 §B3 | P1 的 `stall_ms/stall_count` 口径直接照此;"只减最大一段"无任何先例,否决 |
| Gregg off-CPU:墙钟分解须**成对报告 + 归因**,只报净数无法行动 | 调研 §B2 | 1B' 下由 `generation_ms`(净)+ `stall_ms`/`stall_count` 成对报告满足,墙钟 = `generation_ms + stall_ms` 可还原 |
| NVIDIA TTFT/ITL/E2EL:排队计入 TTFT,基准口径**不扣除**沉默 | 调研 §B1 | 扣沉默是库自造口径,必须显式声明、不与基准口径混写 |
| vLLM `generation_time_ms` 排除条款:token 数 <2 时间隔类字段 null;`n>1` 无法归因即抑制 | 调研 §B3 | 三新字段的 None 条款照抄:增量 chunk <2 / 非流式 → None |
| Envoy/gRPC:判赢在**响应头**即 commit 不回头;per_try_timeout 只在响应开始前有效,中段停顿不重新对冲 | 调研 §A2 | P2 赢家裁定与"不回头"语义的参照;约束未来流式透传(§5.2) |
另:配置命名先例对照见调研 §3.3,本设计键名与其对齐。
## 3. 共享观测原语:增量 chunk 单调时间戳序列
P1(计量)与 P2(中段触发)共用同一份底层观测,避免两套计时逻辑漂移:
| 项 | 设计 |
| --- | --- |
| 记录点 | transport 层(`openai_compat.py:580-590` 现算 `max_gap` 的循环处已逐 chunk 迭代,时间戳序列在此追加记录,增量成本近零;:605 是 `max_inter_token_ms` 装配处) |
| 记录内容 | **chunk = 任何增量产出(`content` 或 `reasoning_content` 都算),与看门狗/首 token 活性口径一致**(`openai_compat.py:585-588` 注释"思考增量同样是存活证据"同款);**仅空 chunk / usage 帧 / SSE keepalive 不计时**(keepalive 字节会重置 httpx read timeout,对"有字节无内容"的卡顿失效,本序列天然免疫,LangChain 语义见调研 §A3) |
| 派生原则 | 一切计量(`stall_ms` 等)与中段触发信号(§5.2)都从此序列/其增量事件派生,**不在热路径做判定**(调研 §3.2-1);热路径只做"记一个时间戳 + set 一个 Event" |
| chunk ≠ token | chunk 边界是传输/解析缓冲产物,非 token 边界(vLLM #27485 陷阱,调研 §B1);一切间隔类口径按 chunk 声明,不假装是 ITL |
P2 触发所需的活体信号由此派生:transport 每记一个增量 chunk 即刷新一个 `asyncio.Event`(语义 = "自上一增量 chunk 以来未再沉默"),编排侧以"等该事件超时"实现 stall 计时器——与 1.3.7 首 token 事件同构,零新机制类别。
## 4. P1 方案:沉默计量口径(1B' 已批准)
### 4.1 方案 1A:不改库,下游自算 —— 已否决
| 项 | 内容 |
| --- | --- |
| 语义 | 下游用既有 `LLMResponse.max_inter_token_ms`(`types.py:406`)自算 `generation_ms − max_inter_token_ms`,即**只减最大一段**;库仅澄清文档 |
| 改动面 | README/CHANGELOG 口径澄清,零代码 |
| 优点 | 零实现、零契约变更;`max_inter_token_ms` 已在遥测表(`telemetry/schema.py` COLUMNS),存量数据即可分析(本次实测正是用它定位的病) |
| 缺点 | 多段沉默场景**系统性高估**活跃时间(调研 §B3:只减最大一段无任何先例);口径责任散落每个下游,各家算法漂移后跨项目不可比;无 `stall_count`,违反 Gregg 归因原则 |
**否决理由(人类裁决,2026-09-11)**:口径必须库内收敛,不得散落各下游自算。
### 4.2 方案 1B'(已批准):`generation_ms` 口径重定义
人类原话:"如果 generation_ms 与 max_inter_token_ms 本质是一样的那能不能改一下 generation_ms,减去所有大于 stall_ms 的中段沉默得到一个纯净的时间"。PM 澄清两字段本质不同(完整墙钟 vs 单次最大沉默段)后,人类意图收敛为:原草案 1B 的 `active_generation_ms` 净口径**直接写进 `generation_ms` 本身**,不新增派生字段。
| 项 | 内容 |
| --- | --- |
| 语义 | `generation_ms` 重定义为**净生成时间** = 赢家 transport 墙钟 − Σ(所有 > 阈值的相邻**增量 chunk** 沉默,pi-tps 式口径);`CallStats` 新增两归因字段(默认 None):`stall_ms: int \| None`(沉默总额)、`stall_count: int \| None`(= **超阈值相邻间隔个数**;pi-tps"连续停顿合并一次"子句不移植——gap 口径下任意两超阈 gap 间至少隔一个 chunk,无可合并对象,该子句是 pi-tps 事件流语义、在本口径下无操作定义,§11-N6);**不新增 `active_generation_ms`**——`generation_ms` 本身就是净口径 |
| 边界与换算(§11-N1/N4/N8 钉死) | 净值 = 墙钟 − 中段沉默,**仍含 TTFT 与尾部**(末 chunk→流结束);尾部归活跃侧是有意选择(墙钟基底口径,尾部通常≈0),与 pi-tps"止于最后更新"的差异显式声明,README 口径表同步。钟同源纪律:沉默序列(transport)与墙钟(retry 注入 `now`)生产同为 `time.monotonic`。换算只此一处:TransportResult `stall_ms: float \| None`;retry 层 `wall_ms=int((now−gen_started)*1000)` 照旧,`net = max(0, wall_ms − int(stall_ms or 0))`(clamp 防御);`CallStats.stall_ms = int(stall_ms)`(None 透传)——各实现不得另取整。FakeClock 测试用自控 stall 值的假 transport,真实 transport 测试不注入假钟 |
| 归因与还原 | 墙钟 = `generation_ms + stall_ms`;净数与沉默总额/次数成对报告,Gregg 成对报告原则(调研 §B2)在净口径下依然满足 |
| 排除条款(照 vLLM,与原案一致) | 非流式 / 增量 chunk <2 → `stall_ms`/`stall_count` 为 None 且 `generation_ms` **不退减**(= 原墙钟);增量口径与看门狗一致(`content`/`reasoning_content` 均计)——reasoning 模型长 thinking 阶段只流 reasoning_content,若只计 content 会把正常推理误计为沉默(P1 虚高、P2 误触发);非流式场景 net≈wall(无中段沉默概念),下游预算公式以 `stall_ms is None` 为分支条件;对冲输家的沉默**不进**赢家快照——已完成但竞速落选的输家其沉默在其 attempt 遥测行(可 join 还原);被取消的输家无 TransportResult、行是 `hedge_cancelled` error 行、stall 恒 None,其存在性由该行表达 |
| 阈值 | 新配置 `{SCOPE}__STREAM__STALL_THRESHOLD_MS`,**默认 1000ms**(§9-③ 拍板);人类附加要求"需要是一个可以调整的参数,对外提供调整接口"——调整接口 = 既有装配路径(env 键 / `GatewaySettings` 字段 / `GatewayClient` 构造 kwarg 三层均可调),**不提供 per-call 覆盖**(§8 非目标 F);**不做自适应**(防与对冲阈值耦出反馈环,调研 §3.2-3) |
| 遥测 | 遥测表新增 `stall_ms`/`stall_count` 两列(§9-⑤ 拍板):`telemetry/schema.py` PG/SQLite 各一次可空 DDL + recorder/`middleware/telemetry.py` 透传;`generation_ms` **仍不进遥测列**(YAGNI——下游要墙钟用 `latency_ms` 或 `generation_ms + stall_ms` 还原) |
| 改动面 | `types.py:296-324`(`CallStats` 增两字段);`ports.py` `TransportResult` 增 `stall_ms`/`stall_count: float \| None` 默认 None;`openai_compat.py:580-590`(max_gap 循环邻域;:605 是装配处)从 chunk 时间戳序列计算两值(沿用 §3 原语);`middleware/retry.py:332/346` 邻域(赢家记账改为 `record_generation`(净值) + context 存 stall 两值进快照,口径与 H8 同款);遥测透传链路(PM 裁决选 (b):LLMResponse 不增顶层字段——CallStats 已承载赢家口径,避免同数据两处平铺漂移):`middleware/telemetry.py` `_emit` 显式从 TransportResult 传 stall 两值 + emitter `_AttemptUsage` 增两字段 + `ports.py` `TelemetryRecorder.record_llm_call` 签名加两参(**第三次端口签名变更**,§7 端口行补录;emitter 装配闸 `signature.bind` 以该协议签名为判据,漏改协议即装配期炸)+ 两 recorder 实现(PG/SQLite)透传;`telemetry/schema.py` 编辑点 = DDL 末尾(created_at 后) + `SQLITE_BACKFILL`/`_PG_BACKFILL_DECLS` 保序对齐 + `COLUMNS` + 列数断言测试 + `telemetry_schema_sql` 同源再生成;配置走 `_load_hedge`(`config.py:745-769`)同款 loader + `GatewaySettings` 字段;`client.py` 构造透传 |
#### 契约变更的正当性与披露
语义突变在一般情形下违反"只增不删不改名"精神(§4.3 拒绝理由),本次由人类以"窗口期最小代价"裁决放行,依据:
| 依据 | 事实 |
| --- | --- |
| 字段极新 | `generation_ms` 是 1.3.7(2026-09-10)引入的新字段,发布仅 2 天 |
| 消费者唯一 | 唯一消费者是提出本需求的下游 Terminal-Bench;三参考项目均未消费该字段 |
| 名实自相矛盾 | 字段名"裸生成时间"原本承诺的就是净口径,现实现(墙钟)与名字矛盾,本次重定义是**兑现名字**而非推翻承诺 |
| 无账本断裂 | 遥测 DB 从未存过 `generation_ms`(已验证 `telemetry/schema.py` 与 `middleware/telemetry.py` 均无此字段),库内无历史账本纵向比较断裂 |
披露义务:CHANGELOG 设独立"行为变更"段显著标注 1.3.7→1.3.8 口径变化 + README 口径表更新 + 本设计存档。
### 4.3 方案 1C:改 `generation_ms` 口径为扣沉默 —— 原拒绝理由被人类裁决覆盖(留档)
| 理由 | 依据 |
| --- | --- |
| 已发布契约突变:1.3.7 设计 §4.5 H8 明写墙钟口径并已被下游消费 | 铁律"字段只增不删不改名";语义突变等同改名 |
| 历史账本不可比:遥测 DB 存量 `generation_ms` 全是墙钟,口径切换后纵向比较断裂 | 遥测必录铁律的对账前提 |
| NVIDIA/vLLM 基准口径均不扣沉默(调研 §B1),库把墙钟改掉会与业界定义脱钩 | 口径分层应靠新增字段,不是靠改旧字段 |
**人类裁决(2026-09-11)**:上述理由在一般情形下成立,但人类以"窗口期最小代价"覆盖之(字段发布仅 2 天、消费者唯一、遥测无账本,见 §4.2 正当性表)——净口径以 1B' 形式落地;原 1B 三字段并存案随之被取代。
### 4.4 P1 对比与决策
| 维度 | 1A 下游自算(已否决) | 1B 原三字段案(已被取代) | **1B' 口径重定义(已批准)** | 1C 改原口径 |
| --- | --- | --- | --- | --- |
| 多段沉默准确性 | 差(系统性高估) | 好(减所有超阈段) | 好(减所有超阈段) | 好 |
| 归因能力 | 无(只有最大段) | 有(总量 + 次数) | 有(`stall_ms`/`stall_count` 成对) | 无(只有净数) |
| 口径唯一性 | 散落各下游 | 库内收敛 | 库内收敛 | 库内但破坏存量 |
| 契约风险 | 零 | 只增字段 + 遥测 DDL | 语义突变,经人类以"窗口期最小代价"裁决放行(§4.2 正当性) | **违反只增不删精神,拒绝** |
| 实现量 | 零 | 中(五文件邻域) | 中(六文件邻域,含端口) | 小但不可接受 |
**决策:1B'(§9-① 拍板)**;1A 因"口径必须库内收敛"被人类否决;1B 原案被 1B' 取代;1C 拒绝理由被人类裁决覆盖(留档 §4.3)。
## 5. P2 备选方案:中段沉默的应对
### 5.1 方案 2A:不做中段对冲,看门狗链 + 文档
| 项 | 内容 |
| --- | --- |
| 语义 | 维持业界主流:中段沉默超 `INTER_TOKEN_TIMEOUT_S` → 掐流 → Transient → 既有重试/换源链;库补文档与配置指引(各源默认值、与 `timeout_s` 的回落关系 `openai_compat.py:573`、建议按源沉默分布取值) |
| 改动面 | README/wiki 配置指引,零代码 |
| 优点 | 零实现零契约变更;有 LangChain(默认 120s)与 Codex CLI(300s)生产先例(调研 §A3);掐死即 Transient,四分类不新增语义 |
| 缺点 | 掐掉即**丢弃全部已生成内容**、从头重试;重试可能再卡同一类排队;每步成本 = 一次完整重新生成;实测沉默 >20s 占 19.0%,这条链会频繁点火 |
### 5.2 方案 2B(已批准):中段沉默对冲
| 项 | 内容 |
| --- | --- |
| 触发 | 新配置 `{SCOPE}__HEDGE__STALL_AFTER_S`(**独立于 `HEDGE__AFTER_S` 生效**,§9-④ 拍板"否,独立生效";调研 §3.3 命名);语义:主路**相邻增量 chunk 间隔**超此阈值 → 触发。机制 = §3 原语派生的"增量 chunk Event 等待超时",与首 token 事件同构;`_past_hedge_window`(`retry.py:542-562`)扩为"首 token 未至走 AFTER_S,首 token 已至走 STALL_AFTER_S"。并发纪律(Event + 复检方案,§11-N3):①超时到期后触发对冲前必须复检 `is_set()`(chunk 与超时同一事件循环 pass 到达时防误触发);②check→clear 之间不得有 await;③对冲路 chunk Event 恒 None(1.3.7 首 token 约定延伸声明);④stall 等待每轮迭代自建 waiter,逐字套用 `_past_hedge_window` 的 waiter 收口 + `cancelling()` 穿透纪律(retry.py:556-568);⑤等待者唯一(编排),Event 无需多等待者语义 |
| 对冲执行 | 触发后主路**不取消**;并发异源副路走**完整准入 + 熔断**,无配额静默放弃(1.3.7 设计 §4.3 语义原样复用);`FIRST_COMPLETED` 赛跑;输家取消,est 保留不喂熔断、遥测 `hedge_cancelled`(1.3.7 设计 §4.4/§4.5 全部复用,零新记账语义) |
| 赢家计量 | 赢家路为准:`generation_ms`(1B' 净口径)/`stall_ms`/`stall_count`(1B' 同批落地,§9-①)全部取赢家路;`hedge_won` 语义不变(赢家是否对冲路) |
| 无拼接污染 | `chat()` 是**库内聚合流**——调用方拿完整 `LLMResponse`,流在库内消费完毕才返回;中段切换赢家时两路内容从未交付调用方,**无拼接问题**。这与调研 §3.1 基于"流透传"假设的 commit 语义不同。**约束明写**:未来若做流式透传,commit 点须重审——一旦任一路首 chunk 已透传下游,即按 Envoy"判赢不回头"锁定该路,中段对冲对该次调用永久关闭(§8 非目标:本版不做透传) |
| 与看门狗的交互 | 两参数语义独立:若主路沉默超 `inter_token_timeout_s` 被看门狗掐死,**对冲路已在跑 → 本轮超级尝试不因主路之死而败(等副路);attempt 级记账(breaker `record_failure`/`attempt_fails`/遥测 error 行)照常**;副路未起 → 照旧 Transient 重试。装配守卫升级为硬守卫(§6,§11-N5):`stall_after_s ≥ 有效最小值` → ValueError,对冲窗永不开的情形在装配期直接拒绝。不对称声明:看门狗掐死喂熔断失败 vs 对冲输家取消不喂熔断,同为源沉默事件不同处置——前者是 1.3.6 起既有看门狗语义、后者是 1.3.7 已批准语义,本设计不改任一;运维"挂起率"统计须知此不对称 |
| 成本披露 | 实测沉默 >20s 占 **19.0%**——触发率远高于首 token 对冲(ttft 仅 3/4407 超 20s);每触发一次最多 +1 并发请求 + 一份 est 预扣滞留(1.3.7 §4.4 口径);**默认必须关闭**,文档明写"对冲 = 用配额换延迟" |
| 改动面 | `retry.py`:`__call__` 入口门改为"两阈值(`hedge_after_s`/`stall_after_s`)任一已设即进对冲编排;仅 `stall_after_s` 且请求非流式 → 逐字走单路(等价未配置,中段沉默无非流式概念)"(§9-④ 独立生效的编排落点);`_past_hedge_window`(`retry.py:542-562`)从一次性等待重构为两阶段(首 token 前:`AFTER_S` 已设才等;首 token 后:`STALL_AFTER_S` 循环等待)+ 与看门狗掐死的汇合规则;`ports.py` + `openai_compat.py:569-575/580-590` 邻域(`complete` 加增量 chunk Event 参数——公共端口签名第二次变更,§9 整体批准覆盖);`config.py` 邻域(新键 + 交叉守卫);`client.py` 透传;遥测复用 `hedge_cancelled`,零新列 |
**方案 2C 不单列,作为 2B 的裁定细节**:看门狗掐死瞬间原子地并发补一路(掐与对冲合一)。评估:省一个参数但把"观测阈值"与"掐死阈值"耦合,掐死时副路从零开始(TTFT 全付),总延迟 = 沉默阈值 + 完整生成,严格劣于 2B 的"沉默中提前并发";且与 2A 链叠加时行为难推理。否决,仅留档。
### 5.3 P2 对比与决策
| 维度 | 2A 看门狗链 + 文档 | **2B 中段沉默对冲(已批准)** |
| --- | --- | --- |
| 已生成内容 | 掐掉全丢 | 主路成果保留到最后一刻,输家才弃 |
| 长尾延迟 | 沉默阈值 + 完整重试耗时 | ≈ 沉默阈值 + min(两路剩余) |
| 业界先例 | 有(LangChain/Codex CLI) | **无公开先例**(调研 §A3),属超业界现状能力 |
| 成本 | 每次沉默 = 一次完整重新生成 | 触发率 19% 量级 × 一次额外请求 |
| 公共面变更 | 零 | 端口签名再变 + 新配置键 |
| 实现量 | 零(文档) | 大(但编排/记账/遥测全复用 1.3.7) |
**决策:2B(§9-② 拍板,独立于 `HEDGE__AFTER_S` 生效)**。诚实标注保留:它超出业界现状能力(调研 §A3),人类已明确接受;2A 链(看门狗 → Transient → 重试)作为不启用对冲时的默认行为继续存在,文档指引义务不变;P1(1B')同批落地后下游可直接量化沉默代价。
## 6. 配置汇总(新增两键,默认关闭/合理默认)
| 键 | 值域 | 缺省 | 守卫与既有键关系 |
| --- | --- | --- | --- |
| `{SCOPE}__STREAM__STALL_THRESHOLD_MS` | 正整数毫秒 | **1000**(§9-③ 拍板,可调) | 仅计量口径,不改任何行为;不设 = 用缺省;调整接口 = env 键 / `GatewaySettings` 字段 / `GatewayClient` 构造 kwarg 三层,无 per-call 覆盖;命名对齐调研 §3.3(pi-tps 500ms 先例,但场景不同) |
| `{SCOPE}__HEDGE__STALL_AFTER_S` | 有限正数秒(复用 `ensure_call_deadline` 同款校验) | **未设 = 关闭** | 独立于 `HEDGE__AFTER_S`;仅对流式 chat 请求生效;chat 非流式请求按 §5.2 入口门运行期静默退化——装配期**无法**判定 chat scope 的流式性(`stream` 是 per-call 参数,`client.py:355`/`types.py:470`),故 chat 侧无装配 warning(§11-B4);Embedding/OCR scope(装配期可证明纯非流式)检测到内嵌 gateway 的对冲键(`AFTER_S`/`STALL_AFTER_S`)非 None → 装配 warning(§11-B4,含 1.3.7 `AFTER_S` 同病顺带补齐);`stall_after_s ≥ min over sources of (inter_token_timeout_s or timeout_s)`(有效值,`openai_compat.py:573` 回落同款)→ **ValueError**(与 1.3.7 `hedge_after_s ≥ min(timeout_s)` 同款硬守卫,config.py:820-824——stall 场景无非流式残留收益,warning 论证不足);`stall_after_s ≥ call_deadline_s` → ValueError,落进 `check_hedge_assembly`(config.py:772);单源 scope 设阈值 → warning 允许(1.3.7 §5 同款) |
`STREAM` 段须加入 `_RESERVED_SEGMENTS`(`config.py:57` 邻域,1.3.7 为 `HEDGE` 做过同款);3 段键天然跳过 `_load_sources`(`config.py:427-431`)。装配路径与 1.3.7 对冲同款:`GatewaySettings` 字段 + `__post_init__` 守卫 + `GatewayClient.__init__` keyword-only 入口即校;不提供 per-call 覆盖(阈值是源/渠道特性,1.3.7 §5 同款判据)。
## 7. 与既有契约的关系
| 契约 | 处理 |
| --- | --- |
| `CallStats`/`LLMResponse` 只增不删不改名 | 两新字段(`stall_ms`/`stall_count`)全 `int \| None`、默认 None;1.3.7 及以前构造的位置调用不炸(1.3.7 §4.5 同款论证);`generation_ms` 语义重定义为净口径(**已批准例外**,§4.2 正当性与披露),`max_inter_token_ms` 原语义一字不动 |
| 遥测迁移 | `stall_ms`/`stall_count` 两列可空,PG/SQLite 各一次 DDL;旧行 null;迁移纪律照 issue #12/#13/#15 教训与 1.3.7 §4.5 备选调先例;**加列已拍板**(§9-⑤) |
| 公共端口签名 | `Transport.complete` 随 2B 第二次加参(增量 chunk Event,必填 kw),与 1.3.7 `first_token_event` 同款约定;`TransportResult` 随 1B' 增 `stall_ms`/`stall_count`(默认 None);`TelemetryRecorder.record_llm_call` 签名随 1B' 加 `stall_ms`/`stall_count` 两参(**第三次端口签名变更**,emitter 装配闸 `signature.bind` 以该协议签名为判据)——三次变更均 §9 整体批准覆盖 |
| 文档登记义务 | README 能力表 + CHANGELOG + ARCHITECTURE.md(口径段与对冲触发段)三处同步,发版按 CLAUDE.md §4.4.1 清单执行 |
| 版本归口 | **1.3.8 确认**(人类拍板 P1 + P2 同批发);P1 含 `generation_ms` 口径变更,披露义务照 §4.2;P2 纯增量(新键 + 默认关闭行为) |
## 8. 非目标
| # | 不做 | 理由 |
| --- | --- | --- |
| A | 不做墙钟/净双口径并存字段(如 `active_generation_ms`) | 1B' 拍板净口径直接写进 `generation_ms`;墙钟由 `generation_ms + stall_ms` 还原(§4.2),并存字段徒增口径漂移面 |
| B | 不做自适应分位对冲阈值(bhope/hedge DDSketch) | 列未来方向:per-source 滚动分布跨进程要进 Redis,且与沉默计量阈值耦合有反馈环风险(调研 §3.2-3);本版绝对阈值已够用 |
| C | 不做流式透传 | 透传引入 commit 点与"判赢不回头"约束(§5.2),是独立大设计 |
| D | 不做内容拼接/续写 | 增量文本不可拼接,缝合即结果污染(调研 §3.1) |
| E | embedding/OCR 不动 | 无流式中段概念,沿用 1.3.7 非目标 A |
| F | 不做 per-call 沉默阈值覆盖 | 人类拍板(§9-③):调整接口走装配层(env 键 / `GatewaySettings` 字段 / `GatewayClient` 构造 kwarg 三层),per-call 覆盖超本版范围 |
## 9. 决策记录(2026-09-11 全部拍板)
| # | 决策点 | 拍板结果 | 人类原话 / 选项 |
| --- | --- | --- | --- |
| ① | P1 选型 | **1B'**:`generation_ms` 口径重定义为净时间(原 1B 的 `active_generation_ms` 口径直接写进 `generation_ms` 本身,不新增派生字段);新增 `stall_ms`/`stall_count` 归因字段 | "如果 generation_ms 与 max_inter_token_ms 本质是一样的那能不能改一下 generation_ms,减去所有大于 stall_ms 的中段沉默得到一个纯净的时间"(PM 澄清两字段本质不同后,意图收敛为口径重定义) |
| ② | P2 选型 | **2B**:中段沉默对冲 | 选定 2B(已明确接受"超业界现状能力"标注) |
| ③ | `STREAM__STALL_THRESHOLD_MS` 默认值 | **1000ms,可调** | "需要是一个可以调整的参数,对外提供调整接口"——接口 = 装配三层(env 键 / `GatewaySettings` 字段 / `GatewayClient` 构造 kwarg),不提供 per-call 覆盖 |
| ④ | 中段对冲是否以 `HEDGE__AFTER_S` 已启用为前提 | **否,独立生效** | 原话"否,独立生效" |
| ⑤ | 遥测是否加 `stall_ms`/`stall_count` 两列 | **加** | 选定"加"(归因进 DB,可对账) |
另(§9 之外拍板):P1 + P2 **同批发 1.3.8**(§7)。
## 10. 自查记录
- 行数 231 ≤400;无超 15 行代码块(全文无代码块,仅表格与伪语义表述)。
- 方案改动面均列具体文件:行号(1B'、2B);行号以 2026-09-11 独立审查核实记录为准(全部实读源码,见 §11)。
- 调研结论引用处标注(调研 §x.y);未重查网络。
- 2026-09-11 批准确认:§9 五项 + 同批发 1.3.8 全部落地本文;1A/1C 保留为否决留档,1B 原案标注被 1B' 取代。
- 2026-09-11 独立审查补遗:`reviews/2026-09-11-streaming-stall-design-review.md` 的 B1-B3/N1-N9 经 PM 逐条裁决全处置,修正直接并入正文对应小节,§11 只做索引;§9 五项拍板不变。
## 11. 独立审查补遗(2026-09-11)
审查报告 `reviews/2026-09-11-streaming-stall-design-review.md` 裁决"有条件实施"(3 阻断 + 9 建议);PM 逐条裁决如下,全部修正已并入正文对应小节(**本节只做索引,正文为准**)。§9 五项拍板不变。
| 发现 | PM 裁决 / 处置 | 正文落点 |
| --- | --- | --- |
| B1 chunk 定义未钉死 | chunk = 任何增量产出(`content` 或 `reasoning_content` 都算),与看门狗/首 token 活性口径一致;仅空 chunk/usage 帧/SSE keepalive 不计时;排除条款"内容 chunk <2"改"增量 chunk <2"(reasoning 模型长 thinking 只流 reasoning_content,只计 content 会误计沉默:P1 虚高、P2 误触发) | §3 记录内容行、§4.2 语义/排除条款、§2 vLLM 行 |
| B2 遥测透传链路遗漏 | PM 裁决选 (b):`_emit` 显式从 TransportResult 传 stall 两值,LLMResponse 不增顶层字段(CallStats 已承载赢家口径,避免两处平铺漂移);`ports.py` `record_llm_call` 签名加两参(第三次端口签名变更,补进 §7 端口行);emitter `_AttemptUsage` + 两 recorder 实现 + schema.py 五处编辑点全列;输家表述修正:被取消输家无 TransportResult、行是 `hedge_cancelled` error 行、stall 恒 None,存在性由该行表达 | §4.2 改动面/排除条款、§7 端口行 |
| B3 编排入口遗漏 | `__call__` 入口门改"两阈值任一已设即进对冲编排;仅 `stall_after_s` 且请求非流式 → 逐字走单路(等价未配置)";`_past_hedge_window` 从一次性等待重构为两阶段(首 token 前 AFTER_S 已设才等 / 首 token 后 STALL_AFTER_S 循环等待);纯非流式 scope 设置给装配 warning | §5.2 改动面、§6 配置表 |
| N1 计时边界未明写 | 净值 = 墙钟 − 中段沉默,仍含 TTFT 与尾部;尾部归活跃侧是有意选择(墙钟基底,尾部≈0),与 pi-tps"止于最后更新"的差异显式声明,README 口径表同步 | §4.2 边界与换算行 |
| N2 非流式双口径 | 非流式 net≈wall(无中段沉默概念);下游预算公式以 `stall_ms is None` 为分支条件 | §4.2 排除条款 |
| N3 Event 并发纪律 | 采 Event + 复检方案:①超时到期后触发前复检 `is_set()`;②check→clear 之间无 await;③对冲路 chunk Event 恒 None(1.3.7 首 token 约定延伸);④每轮迭代自建 waiter,逐字套用 `_past_hedge_window` waiter 收口 + `cancelling()` 穿透;⑤等待者唯一(编排) | §5.2 触发行 |
| N4 钟同源与负值防御 | 沉默序列(transport)与墙钟(retry 注入 `now`)生产同为 `time.monotonic`;`net = max(0, wall − stall)` clamp;FakeClock 测试用自控 stall 值的假 transport,真实 transport 测试不注入假钟 | §4.2 边界与换算行 |
| N5 守卫三缺口 | 守卫用有效值 `min over sources of (inter_token_timeout_s or timeout_s)`(回落同款);`stall_after_s ≥ 该有效最小值` → **ValueError**(与 1.3.7 `hedge_after_s ≥ min(timeout_s)` 同款硬守卫——stall 场景无非流式残留收益,warning 论证不足);`≥ call_deadline_s` → ValueError,点名落进 `check_hedge_assembly` | §6 配置表、§5.2 与看门狗交互行 |
| N6 `stall_count` 定义 | = 超阈值相邻间隔个数;删"连续停顿合并一次"子句(gap 口径下任意两超阈 gap 间至少隔一个 chunk,无可合并对象,该子句移植自 pi-tps 事件流语义、在此无操作定义) | §4.2 语义行 |
| N7 引用勘误 | CallStats 在 `types.py:296-324`(原"417-418 邻域"错位,417-418 是 LLMResponse 邻域,`max_inter_token_ms` 在 :406);`config.py:401-407` → `:427-431`;"`config.py:46` 邻域" → `_load_hedge`(`config.py:745-769`)同款 loader + `GatewaySettings` 字段(:46 是 per-source 四段键表 `_SOURCE_FIELDS`,与 §6 三段键矛盾);max_gap 循环在 `openai_compat.py:580-590`(:605 是装配处);遥测编辑点 = DDL 末尾(created_at 后) + `SQLITE_BACKFILL`/`_PG_BACKFILL_DECLS` 保序对齐 + `COLUMNS` + 列数断言测试 + `telemetry_schema_sql` 同源再生成;§5.2"须进批准项"改"§9 整体批准覆盖" | §3、§4.1/§4.2/§5.2 改动面、§6、§7 |
| N8 换算钉死 | TransportResult `stall_ms: float \| None`;retry 层 `wall_ms=int((now−gen_started)*1000)` 照旧,`net = max(0, wall_ms − int(stall_ms or 0))`;`CallStats.stall_ms = int(stall_ms)`(None 透传)——换算只此一处,各实现不得另取整 | §4.2 边界与换算行 |
| N9 看门狗措辞 | "主路之死不算失败"改"本轮超级尝试不因主路之死而败(等副路);attempt 级记账(breaker `record_failure`/`attempt_fails`/遥测 error 行)照常";补不对称声明:看门狗掐死喂熔断失败 vs 对冲输家取消不喂熔断,同为源沉默事件不同处置——前者是 1.3.6 起既有看门狗语义、后者是 1.3.7 已批准语义,本设计不改任一;运维"挂起率"统计须知此不对称 | §5.2 与看门狗交互行 |
| B4(计划期补遗,2026-09-11 PM 裁决) | "纯非流式 scope 设置时装配 warning"(B3/§6 原文)字面不可实现:装配期无法判定 chat scope 流式性(`stream` 是 per-call 参数)。操作化:①装配 warning 只落在 Embedding/OCR scope(装配期可证明纯非流式)——`EmbeddingSettings`/`OcrSettings.__post_init__` 检测内嵌 gateway 的 `hedge_after_s`/`hedge_stall_after_s` 非 None → warning"对冲键仅对 chat scope 生效,本 scope 非流式静默退化";`AFTER_S` 一并纳入(1.3.7 同病顺带补齐,属缺陷修复不扩公共面);②chat 侧装配 warning 明确不做,非流式请求按 §5.2 入口门运行期静默退化;③不新增任何 scope 级 stream 配置键(扩公共面且超 §9 键清单) | §6 配置表 STALL_AFTER_S 行 |
@@ -0,0 +1,171 @@
---
type: design
node_id: design:2026-10-01-issue29-openai-fidelity-design
title: OpenAI消息保真设计
date: 2026-10-01
---
# #29 OpenAI 消息保真设计
> **修订指针(2026-10-14)**: §4.1"未知 delta 拒绝"、§4.2"reasoning-only 不算成功"与"缺 DONE 一律失败"、§5 活性口径、§6 缓存判据已由 [统一完成判定设计](2026-10-14-issue30-31-completion-verdict-design.md)(1.3.10)修订,以后者为准。
> 状态:方向已获人类批准;计划独立审查 P1/P2 已修订并实施,首轮独立代码审查发现的缓存空产物及测试双向依赖已修复,等待父会话最终复核;按人类最终指定准备 1.3.9,取代未发布的 1.4.0;尚未发布。
> 基线:`0e29deac1a72bfee348ed2f8061ea2c33be217e8` / 1.3.8;日期:2026-10-01。
> 需求:[issue #29](https://gitea.iomgaa.online/iomgaa/PolyGateway/issues/29)。
> 权威约束:ARCHITECTURE §2.3、§5、§6、§7.1–7.8;实现前按本文修订对应架构段落。
## 1. 批准记录与范围
用户已明确同意:现有 `chat()` 增加显式 OpenAI 保真模式,`LLMResponse` 增加完整协议结果,复用治理链,修复工具聚合、活性、error 帧和缓存隔离;首版单 choice、不执行工具、不增加 Agent Loop。用户随后确认内部移植代码均为其原创且本人 owner,并明确授权 PolyGateway 使用 Apache-2.0;dissect3 也是其项目。以上不再重复请示。
Codex 不可用,人类已授权原生 subagent 替代独立审查。具体 API 拼写与防御约束由本设计落实,经独立审查后实施;若审查要求改变已批准边界,才重新讨论。设计阶段已结束;实现验证记录见计划末尾,独立代码审查/发布前禁止宣称 issue 已解决。
“保真”是受支持 OpenAI Chat Completions 消息字段和值保真,不是 HTTP 字节级透明代理,不覆盖任意供应商增量方言。model、stream、stream_options 仍由库控制;没有逐块回调、工具执行、同步客户端、美元预算或失败用量取证新功能。
## 2. 源码核验与方案比较
| 已核实接缝 | 现状与修复目标 |
| --- | --- |
| `transports/openai_compat.py::_sse_delta` | 只读首 choice;content 早返回丢同帧 reasoning;忽略 tool_calls/error |
| `_complete_stream` / `_complete_once` | 纯文本判空;raw 只存 usage;需新增协议快照与 tool-only 成功语义 |
| `client.py::chat` / `_build_payload` | messages 已直传,overlay 已合并,无需白名单重写 |
| `thinking.py::validate_thinking_raw` | raw-only 已支持;受管与 raw 冲突是既定保护,不得绕过 |
| `middleware/retry.py::_build_response` | 唯一响应构造出口,新增字段在此搬运,不新增治理循环 |
| `middleware/cache.py` | 已有完整消息/overlay 身份,但嵌套新类型需重建、模式需隔离 |
| 方案 | 优势 | 代价/结论 |
| --- | --- | --- |
| A:chat 显式模式 + LLMResponse 内嵌协议结果 | 生命周期、取消、缓存、遥测和对冲单一实现;兼容旧投影 | 需定义组合约束和缓存身份;**选定** |
| B:新增 chat_completion 入口,复用私有核心 | 接口像 OpenAI | 重复公共签名、输入校验和终态封装,收益不足,否决 |
| C:独立 SDK 客户端或代理自聚合 | 局部改动直观 | 无法自动解决响应上浮/缓存/线路错误,复制治理,否决 |
## 3. 公共契约
### 3.1 模式与类型
在 `types.py` 定义并从包根导出 `ResponseMode` 和 `OpenAIChatCompletion`:
```python
ResponseMode = Literal["text", "openai"]
@dataclass(frozen=True)
class OpenAIChatCompletion:
"""单 choice 的协议消息快照;嵌套 JSON 并非深度不可变。"""
message: dict[str, Any]
finish_reason: str | None
response_id: str | None
usage: dict[str, Any] | None
```
`GatewayClient.chat` 末尾追加 keyword-only `response_mode: ResponseMode = "text"`;`ChatRequest` 末尾同字段同默认。`TransportResult`、`LLMResponse` 末尾追加 `completion: OpenAIChatCompletion | None = None`,已有字段不改名、不改位置。保真模式成功时 completion 必非 None;文本模式不承诺提供 completion。
`Transport.complete` 追加必填 keyword-only `response_mode: ResponseMode`,依照现有端口纪律不使用默认值掩盖漏传。默认 transport 实现和测试 transport 一并适配;不新增 SDK 适配器(当前文件树尚无该实现)。自定义 transport 须实现新参数与返回契约,不支持模式时抛 RequestRejectedError,不静默回退。
completion 是独立于上游容器的深拷贝 JSON 快照;frozen 只保证字段不能重绑,不伪称深度不可变。调用方可用 `copy.deepcopy(response.completion.message)` 构造下一轮;其修改不得污染别次调用、缓存条目或另一路对冲。不上 MappingProxyType,避免与既有 dataclasses.asdict/JSON 序列化冲突。
### 3.2 请求与组合校验
| 条件 | 规则 |
| --- | --- |
| 未知模式 | 公共入口 ValueError,尚未创建逻辑调用上下文 |
| openai + structured 非 None | 公共入口 ValueError;不注入 schema、不自动重问;overlay 的 response_format 仍可直传 |
| openai + n | 仅允许缺省或严格整数 1(不接受 bool);调用 overlay 在入口校验,选中源 extra_body 及最终 payload 在 HTTP 前校验,不接受被 overlay 遮蔽的非法源级 n |
| text + n | 保留旧行为,本次不扩大旧文本多 choice 语义;明确只投影第一 choice |
| 消息 | 保留 assistant/tool/reasoning_content/扩展字段,JSON 值不改写;保真入口校验可序列化并深拷贝,避免并发/重试期间调用方改动 |
| overlay | 复用既有保护键、源级→请求级优先级;保真调用对快照作深拷贝,不原地改调用方容器 |
源级 n 错误属选源后的请求拒绝,抛 RequestRejectedError,finally 结算规则不变。保真响应必须恰好一个 choice;SSE choice.index 必须为整数 0,usage-only choices=[] 合法;非流式一个 choice 可省略 index,存在时必须为 0。上游返回多 choice 是协议失败 TransientError,不能静默截取。
### 3.3 推理所有权不随返回模式变化
复用 `effective_effort`、`resolve_thinking`、`validate_thinking_raw`,不改能力表与档位映射。raw-only 示例前提:源级 enable_thinking=None、reasoning_effort=None,请求级 reasoning_effort=None,然后 overlay 传 `{"reasoning_effort": "max"}`。`chat(reasoning_effort=None)` 是继承源,不是撤销源意图;raw 与任何有效受管意图(含 NONE)并存仍拒绝。raw-only 的 applied_effort 仍 None,不编造受管档位。
### 3.4 兼容投影与上游身份
完整协议 `message` 不过 `_finalize_text`,保留 content 的缺省/null/空串区别、空白、think 标签;非流式保留整个 message 的 JSON 扩展字段。`LLMResponse.content/thinking` 仍是旧 `_finalize_text` 投影,content 为 null/缺省时投影空串;只用于旧消费面和现有遥测,代理回传以 completion 为准。
response_id 是上游 id,允许未上报为 None,绝不拿 call_id 补齐;finish_reason 原样保存,缺失为 None,不从工具存在与否猜测;usage 保存原对象快照(最后一个非 null usage),不因 token 归一或 salvage 修改原文。字段存在但类型不符是协议失败。`length` 原样返回,不修复工具参数;下游决定是否执行。
## 4. 协议聚合与完成规则
新增内部模块 `transports/_openai_completion.py`,只处理 chat 单 choice 协议校验、每尝试聚合与快照;不做 I/O、重试、计时、provider 推理或缓存。现有 openai_compat 保留 HTTP、看门狗、投影、usage 归一化与领域上下文。
### 4.1 SSE 聚合状态
每次 complete 独立创建聚合器,不放 transport 实例共享状态。逐 JSON 帧先校验对象并查 error,再读取 choices/delta、元信息和 usage;坏类型统一转 TransientError,不让 AttributeError/TypeError 逃出治理。
| 字段 | 合并规则 |
| --- | --- |
| content / reasoning_content | 非 null 必为 str,逐片拼接;同帧同时收集;不 trim;从未出现则不造键,仅 null 则保留 null |
| role | openai 模式若出现必须 assistant;只出现 role 不算活性;最终成功必须含 assistant role;缺失失败,不伪造字段 |
| tool_calls | 必为列表;每项 index 必为非负严格整数;按 index 分桶,末尾按 index 排序,索引不输出到最终工具对象 |
| id / type | 每工具的身份字段,缺失片段不覆盖;相同重复幂等,冲突拒绝;不把重复完整 id 拼两次 |
| function.name / function.arguments | 字符串增量按到达顺序逐字拼接,允许空片段;arguments 不 JSON 解析/修复/重排 |
| response id / finish_reason | 首个非 null 固定,后续相同值允许、冲突拒绝;model_reported 沿用既有首有效值口径 |
最终有效工具需非空 id、type="function"、非空 function.name,以及至少出现一次字符串 arguments(允许空串);重复工具 id、缺字段、非 function 工具类型都拒绝,不伪造或丢弃。index 可非连续,按排序输出,不按列表位置猜 index。JSON 参数内容及工具业务 schema 不由库验证。
SSE、非流式及缓存最终快照均要求 role="assistant";缺失或其他 role 为协议失败/缓存 miss;合法 role-only 开头不算活性。
非流式工具是完整列表:保留条目及扩展字段,仅校验上述必要字段/类型/唯一 id,不要求 index;存在的扩展字段不删除。SSE 保真仅支持 role/content/reasoning_content/tool_calls;未知 delta 字段或未知工具增量字段无法推定合并语义,明确拒绝为协议失败,避免宣称任意扩展字段无损;元数据如 logprobs 不属于本协议结果字段,文档明确不返回。
### 4.2 成功、截断与错误
| 场景 | 行为 |
| --- | --- |
| openai:空正文 + 有完整工具 | 成功,不触发空补全重试 |
| openai:非空投影正文、无工具 | 成功;存在任何不完整工具则仍拒绝,不用正文掩盖坏工具 |
| openai:无正文且无有效工具 | 维持 empty_completion 瞬时失败;reasoning-only 不算成功 |
| openai:EOF 无 DONE | 无论 missing_done=salvage、是否已有正文/工具/finish,都失败;不打捞潜在可执行结果 |
| text:EOF 无 DONE | 保持已有 early_eof/retry/salvage 及 estimated/unavailable 行为 |
| 任一 chat 模式出现顶层 error 键 | 立即 TransientError,即使值 null/空对象;不读取后续 usage/DONE 挽救,不缓存 |
| 非流式 200 error / 非 JSON / 坏 envelope | TransientError;HTTP 非 200 分类沿用现有规则 |
text 仍不接收 tool-only 成功,不承诺工具结果;共用解析路径的 error 检查、同帧双文本采集和真实工具进展活性修复适用于两模式。纯文本消费不新增工具完整性要求,以免把未承诺的工具结果变成默认请求失败。
## 5. 活性、治理、取消和可观测
一帧有非空正文、非空推理、非空工具 name/arguments 增量或首次建立工具身份时,产生**一次**进展事件。重复相同身份、空 arguments、空 delta、role-only、usage-only、finish-only、注释不刷新活性。同帧多个字段和两个工具仍只记录一个时间戳,避免制造零间隔或虚增 stall_count。
`stream_with_liveness_timeouts` 继续包进展迭代器;openai_compat 用同一事件更新时间戳、TTFT、max_gap、first_token_event、content_event。保持 `_stall_from_chunk_times` 与 retry 唯一取整点不变;真正沉默照常触发中段对冲。非流式不置两事件,stall=None,只保留纯时长阈值对冲。
请求经原 Telemetry→Cache→Structured(None 直通)→Retry→准入→Transport 链。重试、异源对冲、失败预算、源健康、限流结算均原样复用;输家取消仍保守 est 结算、不喂熔断,赢家携自身完整快照。CancelledError 全栈穿透,连接与 permit finally 释放,无新的 shield 或后台任务。
遵循 structured-logging:不新增数据库列/表/Recorder 参数。`_build_response` 搬运 completion;错误由 transport 抛出,经过既有 RetryMW._emit→TelemetryEmitter 单出口记 attempt/terminal 行。SSE error 使用现有 summarize_body/compose_message 有界摘要,携 source_name/operation=chat,最多记录有界错误体,不在日志新增完整工具参数、密钥或成功消息。
现有 `llm_calls` schema(`research-wiki/schemas/llm-calls.md`)及覆盖指标复用。确定性验收指标:error→成功恰好 attempts=2;tool-only attempts=1;命中 attempts=0;失败不写缓存;无孤儿 permit/task。失败 usage 仍 unavailable,费用仍未知,不删除失败事件;不扩展遥测全文覆盖,tool_calls.arguments 不受现有 TEXT_CAP 截断的已知边界须继续声明。
## 6. 缓存语义
`build_cache_key` 追加 `response_mode: ResponseMode = "text"`;仅非 text 时将 response_mode 写入 key 对象。默认文本 key 逐字不变;这是请求语义维度,不引入包版本/revision。tools、tool_choice、raw effort 已在 sampling/source fingerprint;assistant/tool 消息全字段已在 digest_messages,直接复用。
CacheMW 显式序列化和重建 OpenAIChatCompletion,读时验证容器、单消息/工具必要字段与元信息类型。openai 条目 completion 缺失/null/畸形或无有效产物视 miss+warning,不能补默认伪造协议成功;text 旧条目允许缺新字段。transport 与缓存共用 types 内部纯值判据:完整工具或非空投影正文;无工具时还要求协议原正文非空,不能靠缓存旧投影伪装成功;不收紧快照构造用途。错误条目回源成功后覆写,失败不写。
缓存中的 message/usage 与调用结果独立;命中保留历史上游 id/finish/usage,生成本次 call_id、清除历史 call_stats,最终返回本次 attempts=0;cost=0 延续原口径。缓存读写和序列化失败仅 warning,不冒泡,取消例外穿透;将 `_serialize` 异常纳入缓存降级边界,避免新增嵌套字段扩大失败面。
旧文本缓存可能已被 error+DONE 毒化,不能自动判别。发布文档要求受影响调用集合首次读写前迁移 namespace/salt,保留租户前缀与 epoch;覆盖工厂和 per-call 身份。未迁移仍会回放旧值,不能宣称修复自动清除历史缓存;openai 新身份天然不命中旧文本。
## 7. 旧行为审计与非功能约束
| 既有行为 | 处置 |
| --- | --- |
| 旧字段/默认文本/structured 阶梯/受管推理 | 保留;默认文本同帧漏 reasoning 属修复 |
| HTTP 分类、retry/backoff、两类对冲、TPM/熔断 | 保留,不改算法与预算 |
| 空补全、missing_done salvage | 文本保留;openai tool-only 放行、缺 DONE 强制失败 |
| Redis TTL、命名空间、遥测幂等与单 helper | 保留;新增模式隔离与嵌套协议结果回放 |
| 并发与取消 | 每尝试状态隔离,重试/对冲不得复用聚合器,CancelledError 穿透 |
| 降级方向 | 缓存/遥测 warning;准入后端 fail-closed;不新增后端 |
| 持久化/原子性/恢复 | 仅成功后一次写完整缓存值;沿用后端原子 set;无新的任务持久化或断点状态 |
| 幂等与重复 | 重试/对冲可能产生重复上游计费,库不执行工具,因此不产生本地工具副作用;不承诺上游生成幂等 |
## 8. 测试、许可证与交付门
单元测试复用 MockTransport/SSE/受控事件设施;新增两工具交错分片、同帧 content+reasoning、原样下一轮、raw=max、stream=False、缺 DONE、安全判错、缓存和治理组合。每个行为组须保留基线红、新实现绿日志;不以本阶段基线通过冒充新功能证据。
真实 Redis 只测完整协议缓存往返/隔离与取消关联契约,不 mock Lua。真实模型跑专用通用函数工具双轮冒烟(标 slow,输出 Markdown),复用 `.env` 配置与预算,不在源码硬编码凭据;网络/额度失败如实失败,不改成无覆盖成功。不默认重跑全模型推理矩阵;本次不改 thinking 能力算法。
许可证:采用 Apache-2.0 已授权;实施前核查实际分发文件及注明移植/参考的片段,区分参考协议事实与复制代码。reference/ 只读且不打包;AGPL 仓存在不自动污染本库,也不由授权覆盖第三方权利。发现实际复制的不兼容片段须上报,不擅自改许可证。加入标准 LICENSE 与 pyproject 元数据;只有确有通知义务才加 NOTICE,不捏造第三方来源。
源码 docstring、README、CHANGELOG、ARCHITECTURE 同步;无新 env 键,`.env.example` 仅补 raw-only 源配置提示(不改默认)。Gitea Wiki 当前下线,不重建过期页面。线程代理文档说明:client 在同一常驻事件循环线程内创建/使用/关闭;工作线程 run_coroutine_threadsafe,等待超时/断连取消 Future;不逐请求 asyncio.run,不跨 loop 共享 client。
发布由父会话在独立代码审查、全套件、相关 slow、构建包 LICENSE/元数据核验之后执行;沿 CLAUDE 发布九步,外部验证 registry/Release/包页和下游下载,不仅 bump。issue 只有在可安装产物及验收证据齐备后关闭;本阶段不触发这些动作。
@@ -0,0 +1,251 @@
---
type: design
node_id: design:2026-10-14-issue30-31-completion-verdict-design
title: 统一完成判定设计(#30/#31)
date: 2026-10-14
---
# #30/#31 统一完成判定设计
> 状态: 方向已获人类批准(2026-10-14,四项取舍与五个拍板点按推荐);人类授权"Claude 与独立 subagent 确认无误后直接执行",不再走人类设计审阅。两轮独立审查已闭合(R1 3 阻塞 + 9 应修;R2 通过,2 应修已并入),见 §11。
> 基线: `22609c9`(1.3.9);目标版本 **1.3.10**;分支 `fix/issue30-31-completion-verdict`。
> 需求: [#30](https://gitea.iomgaa.online/iomgaa/PolyGateway/issues/30)、[#31](https://gitea.iomgaa.online/iomgaa/PolyGateway/issues/31)。
> 修订对象: [1.3.9 保真设计](2026-10-01-issue29-openai-fidelity-design.md) §4.1/§4.2/§5/§6、ARCHITECTURE §6.2/§7/§14。
## 1. 问题与根因
两条 issue 是**同一根因**: 库判定"生成结束了、结果可交付"时,依赖库自己的推断(`[DONE]` 存在、正文非空、字段在白名单内、快照字段类型),而不是上游的权威声明(`finish_reason`)与传输完整性(干净 EOF + 结尾 usage)。推断失败一律落 `TransientError` → 重试/换源/计熔断,把确定性结局放大成 `max_attempts` 次付费重抽并推高健康源的熔断计数。
| 表现 | 位置(1.3.9) | 后果 |
| --- | --- | --- |
| #31 缺 `[DONE]` 但 finish+usage 完整 | `openai_compat.py:668-671`(保真无条件拒);`_check_done:714`(文本 `missing_done=retry` 默认同样拒) | magpie/glm 两模式均重试到耗尽;实测 1216s 挂起、dissect3 38 次终败 |
| #30-1 reasoning-only + `length` | `types.py:478-490`、`_reject_empty_completion` | 每次写满 max_tokens 后丢弃重抽 |
| #30-2 未知键 / `tool_calls:null` | `_openai_completion.py:97-98,112-114,139-140,146-147`;**非流式也中**: `OpenAIChatCompletion` 拒 `tool_calls: None`(`types.py:451-453`,已实测) | 渠道一换 100% 失败,且以"瞬时"形态出现 |
| 隐患: 只有 `reasoning` 字段的流 | 活性只认 content/reasoning_content | 两模式被看门狗当无进展 → TTFT 超时 |
| 隐患: 文本 salvage 的截断结果被缓存 | `cache.py:197-204` 写路径无判定 | 半截结果 TTL 内反复回放 |
补充事实: GLM 的 finish_reason 含 `network_error`/`sensitive`;2026-07-20 空补全裁决针对的是 stop/无 finish 的"零增量"抖动(`findings/2026-07-21-m25-acceptance.md:48`);vLLM 等实现每帧带累计 usage,finish 之前的 usage 不证明走完。
## 2. 方案比较
| 方案 | 内容 | 结论 |
| --- | --- | --- |
| A 局部放行 | 按 issue 字面在三处各加例外 | 否决: 流式/非流式/缓存判据继续分叉;文本 #31 残留;`network_error` 被当成功 |
| B 统一完成判定(选定) | 内核纯函数分两步(流完整性 → 结局分类),流式/非流式/缓存读写四处共用;"可交付"与"可缓存"拆两判据;未知字段无损合并 | 一次消除根因;代价是公共字段追加与遥测端口加参 |
| C 透明透传 | 保真模式不做结局判定,原样交回 | 否决: 零增量抖动回归(违背 2026-07-20 裁决),截断流也被交出 |
## 3. 公共契约变更(只增不改)
| 位置 | 变更 |
| --- | --- |
| `types.py` | 新增 `StreamEnd = Literal["done", "eof_after_finish", "salvaged"]`,包根导出 |
| `TransportResult` / `LLMResponse` 末尾 | 追加 `finish_reason: str \| None = None`、`stream_end: StreamEnd \| None = None` |
| `OpenAIChatCompletion` 校验 | `tool_calls: null` 合法(视同无工具,原样保留键) |
| `TelemetryRecorder.record_llm_call` | 追加**无默认值** keyword-only `stream_end: str \| None`(**破坏性**: 自建 recorder 装配期被形状闸拒绝,CHANGELOG 单列) |
| `llm_calls` 表 | 末尾追加可空列 `stream_end TEXT`(落点见 §7) |
| `Transport.complete` 签名 | **不变** |
`finish_reason` 取值: 流式取 choice 0(保真已强制单 choice;文本取 `index` 缺省或为 0 的 choice)首个非 null 值;非流式取 `choices[0].finish_reason`。保真模式非字符串维持 1.3.9 拒绝(Transient);文本模式非字符串记 None。`finish_reason` 不进遥测列(未获批)。
| 场景 | `stream_end` |
| --- | --- |
| 流式收到 `[DONE]` | `"done"` |
| 干净 EOF + finish + 结尾 measured usage | `"eof_after_finish"` |
| 文本 `missing_done=salvage` 打捞 | `"salvaged"` |
| 非流式 / 缓存命中 / 失败行 | `None`(与 `ttft_ms`/`stall_ms` 同"无从观测"口径) |
与 `usage_source` 分工: 后者答"账单可信吗",前者答"内容完整吗"。`eof_after_finish ⇒ measured`(由 §4.2 条件保证);`salvaged ⇒ estimated|unavailable`;`estimated ⇒ salvaged`,逆命题不成立。
## 4. 统一完成判定
### 4.1 位置与形态
全部放 `types.py` 内部纯函数(下划线前缀,不导出): CacheMW 要复用,import-linter 禁止 middleware 依赖 transports,而内核层 `ports : types : errors : streaming` 允许。函数只返回判定值,**不抛领域错误**(错误需 `source_name` 等上下文,由调用点翻译),不捕获任何异常,取消天然穿透。
| 函数 | 职责 |
| --- | --- |
| `_tool_snapshot_ok(message)` | 工具快照是否成立(id/type/function.name/arguments 规则 + 唯一 id);`OpenAIChatCompletion.__post_init__` 复用 |
| `_classify_finish(value)` | 映射到 finish 类别(下表) |
| `_stream_is_complete(...)` | 第一步,返回 `StreamEnd \| None`(None = 截断) |
| `_judge_outcome(...)` | 第二步,返回 `deliver` 或 `(reject_transient \| reject_result, 原因码)` |
| `_is_cacheable(...)` | §6 判据 |
| finish 类别 | 值(大小写敏感) |
| --- | --- |
| natural | `stop`、`tool_calls`、`function_call` |
| truncated | `length` |
| filtered | `content_filter`、`sensitive` |
| upstream_error | `network_error`、`error` |
| unknown | 其余非空字符串: 按 natural 交付、**不可缓存**、按源+值告警一次(文案写明"该值不缓存",便于下游定位命中率下降);`end_turn`/`stop_sequence` 等无证据不预先归类 |
| absent | None |
### 4.2 第一步: 流完整性(仅流式)
| 条件 | 结果 |
| --- | --- |
| 读取异常 / 活性超时 / error 帧 / 畸形帧 | 照旧 Transient(不动) |
| 收到 `[DONE]` | `done` |
| 干净 EOF、无 DONE、finish 已见、finish 帧**同帧或之后**收到的 usage 被 `_resolve_usage` 判为 measured | `eof_after_finish`,usage 记 measured(不降级) |
| 干净 EOF、其余情形 | 截断: openai → Transient `missing_done`(仍不打捞);text → 原 `_check_done` 语义(零产物 early_eof、`retry` 抛、`salvage` 记 `salvaged`) |
聚合器每尝试记录 `finish_seen` 与 `usage_after_finish`: 收到非 null usage 时,若 `finish_seen` 已真或同帧带 finish,则置后者为真;是否 measured 以**最后一个**非 null usage(与计费所用 `sink["usage"]` 同一对象)在流结束时判定;usage-only 帧(`choices: []`)在 finish 之后到达是常规形态,正确置位。状态随重试/对冲每尝试新建。
诚实边界: 中转站在上游出错后主动正常结束响应,与真完整在传输层不可区分;防线是"finish 必须存在、不属 upstream_error、且其后有 measured usage",文档如实写明。
### 4.3 第二步: 结局分类
产物定义——**保真模式**: 任何产物 = 消息中任一非空字符串片段(content、reasoning_content、reasoning、refusal、合并保留的未知字符串,空白也算)或至少一个非 null 工具条目;**文本模式**: 任何产物 = 投影 content 或 thinking(含 §4.4 别名)非空串(含空白),未知字段与工具增量只计活性不计产物,`_check_done` 零产物判据同口径。两模式**正文产物** = 投影 content strip 后非空,或(仅保真)工具快照成立且非空。
表格**自上而下取首个匹配行**:
| # | 条件 | openai 模式 | text 模式 |
| --- | --- | --- | --- |
| 1 | finish 为 upstream_error | Transient `upstream_finish_error` | 同左 |
| 2 | (仅保真)存在工具条目但 `_tool_snapshot_ok` 不成立 | truncated → ResultInvalid `tool_truncated`;其余 → Transient `tool_incomplete` | 不适用 |
| 3 | 无任何产物 + truncated/filtered | 交付(#30;隐藏推理模型写满预算的形态) | ResultInvalid `length_without_content` / `filtered_without_content` |
| 4 | 无任何产物 + natural/unknown/absent | Transient `empty_completion`(保 2026-07-20 裁决) | 同左 |
| 5 | 有正文产物 | 交付 | 交付 |
| 6 | 有产物无正文 + truncated | 交付(#30) | ResultInvalid `length_without_content` |
| 7 | 有产物无正文 + filtered | 交付 | ResultInvalid `filtered_without_content` |
| 8 | 有产物无正文 + natural/unknown | 交付 | Transient `empty_completion`(待证据;消息带 finish 与 thinking 长度) |
| 9 | 有产物无正文 + absent(流式仅 DONE 收尾,或非流式 body finish 为 null) | Transient `empty_completion` | 同左 |
ResultInvalid 理由: `retry.py:409-414` 对其"不重试、不换源、熔断记成功不计窗口";length/过滤是预算或内容策略的确定性结局,不是源坏了。对冲下原路 ResultInvalid 等对冲路裁定,两败原样上抛(`retry.py:548-560`);StructuredMW 只捕 parse 抛出的 ResultInvalid,不会误重问(`structured.py:79`)。已知代价: 该分支 TPM 全额退款、用量不落成本(`actual=0`),与 Transient 丢成本同级,登记为已知边界。文本 length 改抛 ResultInvalid 后不再经 `AllSourcesExhausted` 包装,§10 要求核对下游 catch 点。
原因码以固定格式 `"{源} {code}: …"` 写进异常 message,从而进遥测 `error` 列(LIKE 可检索);不新增列、不改 `_failure_reason`(Transient 一族熔断原因记 `network_error` 属已知口径)。
保真模式行 2 之外的工具快照成立 + `length`: 交付,arguments 可能是半截 JSON——docstring 与 CHANGELOG 提醒调用方先查 `finish_reason` 再执行工具。非流式跳过第一步,直接以 body 的 finish_reason 走第二步。
### 4.4 文本模式 reasoning 别名
投影 `thinking`: 本次尝试出现过**非空** `reasoning_content` 则只用它,否则用 `reasoning`。保真 `completion.message` 保留上游原键不改名。两模式活性、产物摘要、`thinking_observation` 均基于该投影。
## 5. 字段合并与活性
### 5.1 null 与已知字段
null 规则**优先于**已知字段类型校验: chunk、choice、delta、工具条目、function 五层任意键值为 null 视为缺省,数组中的 null 元素(如 `tool_calls: [null]`)跳过;键从未出现时保留 `key: null`(含 `tool_calls`、`refusal`、`role`、`function.arguments`)。保真成功仍须最终 role=assistant(全程只有 `role: null` 即缺 role,照旧失败)。其余已知字段(content/reasoning_content/role/tool_calls 及工具 id/type/function.name/arguments)非 null 时语义逐字沿用 1.3.9。
### 5.2 未知字段(delta、`tool_calls[]` 条目、`function` 三层统一)
| 值 | 规则 |
| --- | --- |
| 标签键 `index`/`type`/`id`/`format`(任意未知层级) | 同值幂等,不拼接;不同值 = 冲突 |
| 其他字符串 | 按到达顺序拼接(OpenAI SDK 流式累加惯例) |
| 数字/布尔 | 同值幂等;不同值 = 冲突(不学 SDK 相加) |
| 对象 | 递归套用本表 |
| 数组且元素均为带整数 `index` 的对象 | 按 index 合并(`reasoning_details` 一类);合并策略由该键首次出现形态决定,之后形态改变 = 冲突 |
| 其他数组 | 追加 |
| 冲突 | 删除该键路径,本尝试内不再接收;按 (源, 归一路径) 告警一次 |
冲突删键是有损的: docstring 与 CHANGELOG 写明"可能缺字段,以告警为准"。上游方言确定,重拉同样结果,不视为缓存毒化。
### 5.3 活性
已知字段活性逐字沿用 1.3.9(非空 content/reasoning_content、工具 name/arguments 非空增量、首次建立工具身份)。未知字段仅当本帧在非标签键上并入非空字符串片段(含追加型数组中的字符串元素)时算进展(`reasoning` 由此计入);同帧只记一次。残余风险如实登记: 上游每帧重复下发的非标签字符串(如 `status`)会被拼接并持续刷新看门狗。文本模式同口径。README 中"增量 chunk = content 或 reasoning_content"的 stall/TTFT 口径表述同步更新为本节口径。
### 5.4 告警节流
节流集合放 transport 实例(与 `_warned_mismatches` 分立,纯 asyncio 中立),键为 (源, 归一路径) 与 (源, 未知 finish 值):路径中数组下标归一为 `[]`,finish 值截断至 64 字符;集合上限 1024,饱和后记一次"告警已饱和"不再增长。日志只写源名与路径,不回显值(可能含工具参数)。聚合器以只读集合交出冲突路径,transport 负责告警。
## 6. 可交付 vs 可缓存
可缓存 ⊂ 可交付。两模式同一判据 `_is_cacheable`,读写共用:
| 条件(任一成立即不可缓存) | 理由 |
| --- | --- |
| `stream_end == "salvaged"` | 截断结果,修现存毒化 |
| 无正文产物 | 不把空回答固化进 TTL |
| finish 类别为 truncated/filtered/unknown | 截断有随机性,同 key 换时可成功;未知值不可证明是自然结束 |
| openai 模式 completion 原文 content 为空且无工具(沿 1.3.9) | 不信任旧投影 |
| 路径 | 规则 |
| --- | --- |
| 写 | 不可缓存 → 跳过,debug 日志 |
| 读 | `_is_cacheable` 在命中覆盖(`cache.py:224` `fields.update`)**之前**、对存储原值判定;不可缓存 → miss + warning |
| 读: 非法 `stream_end` | 不在 Literal 域内 → miss + warning(关乎内容完整性,故与 `_coerce_observation` 的宽容方向相反) |
| 读: 顶层无 finish_reason | 回退 `completion.finish_reason`(1.3.9 保真 length 条目因此可识别);文本旧条目两字段缺省 → None → 仍可命中 |
| 命中返回 | `stream_end=None`;`finish_reason` 取判定所用值(顶层缺省时为回退值),非字符串视 miss |
| 序列化 | `_RESPONSE_FIELDS` 由 `dataclasses.fields` 自动派生,无需改动 |
旧 salvage/length 文本污染无法辨认: CHANGELOG 继续要求受影响集合迁移 namespace/salt。
## 7. 遥测落点
| 位置 | 变更 |
| --- | --- |
| `middleware/telemetry.py` `_AttemptUsage.of(response)` | 读 `response.stream_end`(本次 LLMResponse 有字段,不像 1.3.8 走 `emit_attempt` kw,避免约 50 处调用点) |
| `_record` | 新增参数,单一 helper 下沉 recorder |
| `emit_cache_hit` / `emit_terminal_failure` / 失败与取消 attempt 行 | 显式 None |
| `telemetry/schema.py` | SQLite/PG 两份 CREATE 末尾、`SQLITE_BACKFILL`、`_PG_BACKFILL_DECLS`(列序对齐)、`COLUMNS`;`telemetry_schema_sql()` 随之输出 |
| sqlite/postgres recorder | 按 `COLUMNS` 写入;auto 档幂等补列、manual 档裁剪写入并点名缺列(沿 1.3.8) |
| tests | 约 20 处测试替身 `record_llm_call` 补参数 |
| 文档 | README "必录字段数"按 `inspect.signature` 实测更新;`schemas/llm-calls.md` 加列并修正陈旧计数 |
## 8. 旧行为审计
| 既有行为 | 处置 |
| --- | --- |
| 保真缺 DONE 一律失败 | **替换**: §4.2 判完整时成功;其余仍失败、仍不打捞 |
| 文本 `missing_done=retry` 遇缺 DONE 即抛 | **替换**: 同上;`missing_done` 只管真截断 |
| 文本 salvage + estimated | 保留,另标 `salvaged`,不再写缓存 |
| 零增量 + DONE → Transient | **保留**(`test_stream_zero_content_with_done_is_transient` 红线回归) |
| 保真 reasoning-only/空产物失败 | **替换**: §4.3 行 3/6/7/8 |
| 未知 delta/工具/function 字段拒绝;null 已知字段拒绝 | **替换**: §5 |
| 非流式 `tool_calls: null` 拒绝 | **替换**: 合法 |
| 多 choice/index≠0 拒绝、最终 role 必须 assistant、error 帧立即失败 | 保留 |
| 工具快照强校验 | 保留;仅 length 截断时改归 ResultInvalid |
| finish_reason 冲突拒绝(Transient) | 保留(登记: 确定性方言,将来可改 ResultInvalid) |
| 已知字段活性口径 | 保留;未知字段按 §5.3 增补 |
| 缓存写路径无判定 | **替换**: 按 `_is_cacheable` |
| 文本 n>1 混流 | 不在范围,另开 issue |
## 9. 非功能维度
| 维度 | 回答 |
| --- | --- |
| 并发与取消 | 新状态在每尝试聚合器/sink 内;判定为纯函数,不新增 try/except,`CancelledError` 原路穿透;finally 结算与 permit 释放不变 |
| 降级方向 | 缓存跳写/视 miss 只 debug/warning;遥测新列缺失按 manual 档裁剪 + 告警,不冒泡;限流/熔断不涉及 |
| 幂等与重复 | 减少重复付费重抽;标签键与身份字段幂等;告警节流有界 |
| 持久化与原子性 | 仅新增一可空列,补列沿既有探测后 ALTER 幂等流程;缓存仍成功后单次原子 set |
| 错误分类 | 仅落 Transient 与 ResultInvalid 两类,不新增类;原因码见 §4.3 |
| 可观测 | `stream_end` 字段 + 遥测列;冲突路径与未知 finish 有界告警;遵 structured-logging 更新 `schemas/llm-calls.md` |
## 10. 测试、文档与交付
全部用假 SSE / MockTransport 复现,每条先红后绿;真实中转冒烟标 slow 按发布清单 diff 交集跑。
| 组 | 用例 |
| --- | --- |
| #31 验收 | 1 stop+usage+EOF 成功、`eof_after_finish`、measured、attempts=1;2 tool_calls 同上工具完整;3 无 finish 即关 → 失败;4 finish 无 usage → 失败;5 中途 `RemoteProtocolError` → 失败;6 带 DONE → `done` 不变 |
| #31 补充 | usage 早于 finish(累计 usage)+EOF → 不完整;finish 后 usage 为 `{}` → 不完整;`network_error`+usage+EOF → Transient;文本模式 1/3/4(`retry`、`salvage` 两档) |
| #30 验收 | reasoning-only+length → 交付、content 空、思考完整、attempts=1;流式 `refusal:null`+`tool_calls:null` 成功;非流式 message 带 `tool_calls:null`+`refusal:null` 成功 |
| 结局表 | §4.3 每行至少一例,两模式、流式与非流式;文本 length → ResultInvalid 且熔断未计失败;零增量+DONE(带 stop/不带 finish)仍 Transient;content 非空 + 坏工具 + stop → Transient |
| 合并/活性 | 只有 `reasoning` 刷新活性不超时;二次构造 OpenRouter `reasoning_details` 往返(`format`/`id` 不拼接);工具层嵌套对象原样保留可回传;数值冲突删键、告警一次、调用成功;节流饱和;`role:null`/`arguments:null` 视缺省 |
| 缓存 | 四类不可缓存均不写、注入后读视 miss;非法 `stream_end` 视 miss;1.3.9 旧文本条目仍命中;1.3.9 保真 length 条目经回退识别为不可缓存;命中 `stream_end=None` |
| 遥测 | sqlite/pg(integration)新列写入、旧库补列、manual 档裁剪;形状闸拒绝缺参 recorder |
| 改写 | `test_openai_never_salvages`;`test_invalid_delta_is_domain_error` 中 `{"audio": {}}`、`{"index":0,"extra":1}`、null 已知字段参数;提交说明列出 |
同步: 源码 docstring、README(能力表、`1.3.*` 安装约束核对、活性口径、字段数)、CHANGELOG 1.3.10(行为变更段、破坏性段: recorder 端口、namespace/salt 提醒、半截工具参数提醒、冲突删键提醒)、ARCHITECTURE §6.2/§7/§14、1.3.9 设计加修订指针、`schemas/llm-calls.md`、`research-wiki/migrations/` 三份核对下游对 ResultInvalid 的 catch 点、`docs-convention.md` §2 清单。另开 issue: 文本 n>1 混流;文本"有产物无正文 + stop"证据收集。发布按 CLAUDE.md §4.4.1 九步,#30/#31 在 registry 可安装且验收证据齐备后关闭。
证据: `retry.py:404-450,548-560`;`cache.py:197-236`;`structured.py:79`;[openai-python `_streaming.py`](https://github.com/openai/openai-python/blob/main/src/openai/_streaming.py) 与 `lib/streaming/_deltas.py`;[vLLM reasoning outputs](https://docs.vllm.ai/en/latest/features/reasoning_outputs/);GLM finish 枚举来自第三方镜像,实现前以真实中转探针再核;Gemini `extra_content` 路径未核实原文,测试按通用嵌套对象构造。
## 11. 审查修订记录
| 轮次 | 意见 | 处置 |
| --- | --- | --- |
| R1 | B1 `tool_calls:null` 被快照拒(非流式现存) | 采纳: §3/§5.1/§10 |
| R1 | B2 结局表行重叠 | 采纳: 首匹配行序 + `_tool_snapshot_ok` 前置 |
| R1 | B3 文本"任何产物"不可计算 | 采纳: §4.3 分模式定义 |
| R1 | S1 eof_after_finish 但 usage 非 measured | 采纳: 条件加 measured |
| R1 | S2/S3 活性冲突与标签拼接 | 采纳: §5.2/§5.3 |
| R1 | S4 告警集合无界 | 采纳: §5.4 |
| R1 | S5 无产物 + length 与 #30 原文冲突 | 采纳(人类授权代决): 保真交付、文本 ResultInvalid、stop/absent 仍 Transient |
| R1 | S6-S9 缓存顺序、遥测落点、原因码、null 优先 | 采纳: §6/§7/§4.3/§5.1 |
| R1 | 建议 1-7 | 采纳 1/2/3/5/6/7;建议 4 不标不可缓存(上游确定性,非毒化) |
| R2 | 结论通过;N1 measured 以最后 usage 判定、N2 非空 reasoning_content | 采纳: §4.2/§4.4 |
| R2 | 建议 1-4: 命中回退值、未知 finish 告警文案、数组字符串活性与残余风险、null 五层与数组 null 元素 | 全部采纳: §6/§4.1/§5.3/§5.1 |
| 代码审查 | I1 保真可缓存须同时要求投影正文非空(think 标签剥净后为空不缓存) | 采纳: `_is_cacheable` |
| 代码审查 | I2 无参函数 `arguments` 全程 null → 快照不成立被判 Transient 重试 | 人类授权代决: 归 ResultInvalid `tool_arguments_null`(确定性方言);不改写交付值(改写破坏保真,重试救不回) |
| 代码审查 | I3 文本 `index:null` 整帧丢弃、`index:False` 冒充 0 | 采纳: null 视 0,bool 拒 |
| 代码审查 | Minor: `[null]` 产出空 `tool_calls`、非流式缺 role 先于判定、告警路径长度有界、README 表述 | 采纳;index 数组缺 index 整键删除登记于 CHANGELOG 提醒 |
@@ -0,0 +1,288 @@
---
type: design
node_id: design:2026-10-21-issue34-ocr-document-design
title: OCR 整份文档解析设计(#34)
date: 2026-10-21
---
# #34 OCR 整份文档解析设计
> 状态: **已获人类批准(2026-10-21,H1/H5/H6 按推荐批准;H2 授权另开两条 issue;H3 待下游确认,不阻塞;H4 发布逐步等指令)**。已完成 Claude 自审与一轮独立 subagent 论证(§12);Codex 一轮审查已完成并逐条处置(§12 审查记录)。
> 基线: `25a90eb`(1.3.10);目标版本 **1.3.11**;分支 `fix/issue34-ocr-document`。
> 需求: [#34](https://gitea.iomgaa.online/iomgaa/PolyGateway/issues/34)(CHSAnalyzer3 把几百篇医学论文 PDF 整份转成带页码正文与表格)。
> 修订对象: [M3 OCR 设计](2026-07-21-m3-ocr-design.md) §4 "multipart 固定名 image.jpg,服务按内容处理"(该论断只在图片样本上验证过,见 §1 E3/E4)、ARCHITECTURE §7.10。
## 1. 问题与根因
issue 把症状拆成两条;**两条同根**: 库把 OCR 输入建模成"一张图 + 写死的标签",把输出建模成"只有框",**没有"文档"这个概念**——输入类型既不声明也不检测,输出不含内容。M3 设计 §4 的"服务按内容处理"是从 35 张图片样本外推到 PDF 的未验证假设。
以下事实 2026-10-21 在真实 MonkeyOCR 服务实测取得(样本为第三方 AI 生成论文,仅本地存证,**不入库**):
| # | 事实 | 设计影响 |
| --- | --- | --- |
| E1 | 服务按**扩展名**分派,仅 `.jpg/.jpeg/.png/.pdf`(大小写不敏感);其余(`.webp`/无扩展名)→ HTTP 500 `{"detail":"Parsing failed: 400: Unsupported file type…"}` | 上传标签必须落在这四个之内;服务把客户端错误包成 500 |
| E2 | 内容与扩展名不符(jpg 字节标 `doc.pdf`)→ HTTP 500 `Parsing failed: is no PDF` | **调用方声明类型会新增一个失败入口**,故类型由库按内容判定 |
| E3 | `/parse`: 同一 PDF 以 `image.jpg` 与 `doc.pdf` 上传,`_middle.json`/`_content_list.json`/`.md` **逐字相同**(只有 message 与包内文件名前缀不同)——文本型 2 页 PDF 与**无文字层的 2 页位图 PDF**(扫描件模拟,`pdftotext` 字符数 0)两种均验证; **旋转页、带批注页未测**;PNG/TIFF/BMP 字节标 `image.jpg` 亦成功(服务按内容解码);前缀 200 字节垃圾的 PDF 以 `doc.pdf` 上传仍被解析 | 问题一在 `/parse` 上**目前未观察到结果差异**,但不是保证(E10);非 PDF 沿用 `image.jpg` 才是零回归;前缀垃圾的 PDF 服务可解析,嗅探须容忍 |
| E4 | `/ocr/text`: PDF 字节标 `image.jpg` → HTTP 200 `success:true`,content=`"The image contains a single character: 1"`(**静默错误结果**);标 `doc.pdf` → 整份全文(无页码) | 问题一的真实危害在 `/ocr/text`,issue 未提及;须一并修 |
| E5 | 截断 PDF / 伪 `%PDF-` 头 / 乱码图 / 空文件 → 全部 **HTTP 500** `{"detail":"Parsing failed: <原因>"}` | 当前 ≥500 一律 `TransientError`,见 §6 |
| E6 | 结果包含 `*.md`、`*_middle.json`、`*_content_list.json`、三份 `*.pdf`;**含图/表的文档另有 `images/*.jpg`**(4 页含 4 图 ≈598KB;无图的 2 页文档没有该目录) | 包体随插图增长;只读取需要的成员,不解出图片 |
| E7 | `_content_list.json` 与 `_middle.json` 逐块 1:1(三样本 29/29、89/89、30/30),但 content_list **无 bbox**、type 词汇不同(`title→text+text_level`、`interline_equation→equation`),且**文本里 `$` 被转义成 `\$`**(134 个 text/title 块中 23 个) | 不用 content_list,见 §3 Q3 |
| E8 | middle 的 text/title 块恰 1 行 1 span,`content` 与 content_list 反转义后 134/134 一致;table 的 span 带 `html`;interline_equation 的 span 内容为裸 LaTeX;image 嵌套 `image_body` 仅含 `image_path`;嵌套 `*_caption/*_footnote` 子块**从未出现** | 内容可只从 middle 取得;caption/footnote 路径无真实样本 |
| E9 | 速度约 **1.5–4 s/页**(2026-10-21 实测:2 页 3.5s/4.0s[位图型]/5.1s/8s[issue 原文],4 页 6.2–6.4s;样本极少、无争用,不是 SLA。验证者指出初稿的「约 1.5 s/页」偏乐观,已订正)。库内**无任何基于延迟的熔断/慢判定**(`grep latency` 在 breaker/admission/sources 无命中) | 长文档不会被"判慢",真正的风险是 §7 的超时被算成失败 |
| E10 | 服务源码(本机 `~/Code/MonkeyOCR`,`a4d853f`,版本 1.1.0 与样本 `_version_name` 一致)核对: ① 分派只看上传文件名后缀(白名单 `api/main.py:435-441`),`pdf` 走 `PymuDocDataset`、其余走 `ImageDataset`(`api/main.py:164-170`;后者先 `convert_to_pdf()`,`magic_pdf/data/dataset.py:231-235`)——**PDF 标成 `image.jpg` 走的是图片数据集分支**,E3 的"逐字相同"只在那份 2 页文本型 PDF 上验证,扫描件/旋转页/带批注页无证据; ② `/parse` 把一切异常(含 400)改写成 `500 "Parsing failed: …"`(`:501-503`),连 `Model not initialized` 也变成同前缀 | 支持嗅探(让 PDF 走它本该走的分支);**前缀 `Parsing failed:` 不能单独作为"坏输入"判据**,只有具体标记白名单可行 |
| E11 | `content_list` 是**展示格式**: 文本经 `markdown_utils` 转义 `* \` ~ $` 四个字符(`ocr_mkcontent.py:148`);医学论文里 `*`(显著性)与 `~` 比 `$` 更常见,污染面大于 E7 的 23/134 | 强化 Q3: 绝不读 content_list 取文字 |
| E12 | **服务端丢弃**脚注/页眉/页脚/页码/印章(默认版面模型 PP-DocLayout_plus-L 把这些标签映射到 `Abandon` → `discarded_blocks`,span `content` 全空,即**未被 OCR**);`figure_title` 映射为普通 `text` 块,故默认部署**不产生**嵌套 `*_caption/*_footnote` 子块(换 `doclayout_yolo` 才可能);`level` 恒为 1;`need_drop` 在该流水线硬编码为 False | 表格脚注(显著性/缩写说明)在服务端就已丢失,库找不回——写成"服务限制"告知下游;`level` 不加字段;嵌套子块仅作最小防御 |
| E13 | `/ocr/text` 对**无法解码的输入**返回 `success:true` + 幻觉文本: 乱码字节(`GARBAGE-NOT-AN-IMAGE`)得到与 E4 完全相同的 `"The image contains a single character: 1"`——即 E4 的本质是"服务把 PDF 当作无法解码的图"。另据源码,该端点内部异常会返回 **HTTP 200 + `success:false` + `content:""`**(`api/main.py:622-629`),而 `recognize_text` 只校验 `content` 为 str、不看 `success`(`monkey_ocr.py:240-247`)。后者**未经线上触发,仅源码推断** | 标签修正只能消除"PDF 被当图"这一类; 对无法解码输入的静默错误是服务行为,库侧要治需另行设计(§12 H2) |
| E14 | 服务并发模型: `/parse` 推理在 sync-only 后端下持有**全局 `asyncio.Lock`**(`api/main.py:183-189`);`supports_async` 取决于 chat 后端是否有 `async_batch_inference`,出厂默认 `lmdeploy` 为 sync-only(`model_configs.yaml`),**线上实际 backend 未知**(启动日志有 `async-capable`/`sync-only` 一行) | 若 sync-only: 每实例 `/parse` 全局串行,我们的长文档会**队头阻塞医院线**; `timeout_s` 须覆盖"排队+处理" |
## 2. 现状代码面
| 位置 | 现状 |
| --- | --- |
| `transports/monkey_ocr.py:207-210` | `_files()` 固定 `("image.jpg", …, "image/jpeg")`,`/ocr/text` 与 `/parse` 共用 |
| `monkey_ocr.py:136-144,147-166` | `_parse_block` 只取 type/bbox;`_parse_pages` 对非法 `page_idx` 回退枚举下标;bbox 含 CHS shim 专用的整数化退化校验 |
| `monkey_ocr.py:79-100` | `_classify_status`: ≥500/429→Transient、401/403→SourceDead、其余→RequestRejected |
| `types.py:1187-1250` | `OcrLayoutElement(type,bbox,page_index)`、`OcrLayoutResult`、`OcrLayoutTransportResult` |
| `ocr.py:625-628` | `_summarize` 用 `hasattr(result,"text")` 区分文本/版面——**对新结果类型是陷阱** |
| `ports.py:105-121` | `OcrTransport` 是 `@runtime_checkable`,加成员会让所有现有实现(测试替身、下游 fake)在 `isinstance` 上不合格 |
## 3. 备选方案对比
### Q1 公共面形态
| 方案 | 做法 | 权衡 |
| --- | --- | --- |
| **A(推荐)** | 新增 `OcrClient.parse_document` + `OcrDocumentPort`;新类型 `OcrDocumentElement(OcrLayoutElement)`(仅增 `text`)与 `OcrDocumentResult`;`parse_layout`/`recognize_text` 签名与返回类型不动 | 医院线零风险;文档线的内容解析故障不波及版面路径 |
| B | 给 `OcrLayoutElement` 追加 `text` 字段,`parse_layout` 同时返回内容 | 违反 issue "parse_layout 不动";`text: str\|None` 在一条路径上恒为 None;医院线耦合到新增的包成员解析,任一故障波及它 |
| C | 只加 `filename/media_type` 参数并把 ZIP 原样交给调用方 | 把校验、数值防御、阅读序整理全推给下游,违背库的价值;每个下游各写一份解析 |
### Q2 上传类型如何确定
| 方案 | 做法 | 权衡 |
| --- | --- | --- |
| **A(推荐)** | transport 按内容嗅探,判定顺序固定: ① 起始为已知图片魔数(JPEG/PNG/TIFF/BMP/GIF/WebP)→ 图片;② 否则前 1024 字节内含 `%PDF-` → PDF(Adobe 阅读器的头部容忍惯例,E3 实测服务亦可解析);③ 否则未知。PDF → `doc.pdf`/`application/pdf`,**图片与未知一律沿用 `image.jpg`/`image/jpeg`**。单点 `_upload_part()`,三个方法共用 | 图片魔数先判,含 XMP 元数据的图片不会因内嵌 `%PDF-` 字样被误判;非 PDF 路径字节级不变;顺带修 E4 |
| B | 调用方传 `media_type`/`filename`(**issue #34 原文提议**) | 声明与内容不符 → E2 的 500;若放在旧两方法上,仍走共享分类 → Transient → 重试并计熔断: 笔误变熔断事件。库本就能从内容判定,声明是冗余信息; 唯一得益场景(前缀垃圾 PDF)已被 A 的 ② 覆盖。**这是对 issue 提议的有意替换,列入 H5 由人类批准** |
| C | 嗅探全部图片格式并精确标注(png→`image.png`) | 零收益;webp/gif 等无对应扩展名反而要拒绝,把现在能成功的路径(E3)变成失败 |
三个方法对同一输入的结局(Codex 审查要求显式化):
| 输入 | `recognize_text` | `parse_layout` | `parse_document` |
| --- | --- | --- | --- |
| 正常 PDF | `doc.pdf`,整份全文(E4 修复) | `doc.pdf`(上传分支变更,见 §8) | `doc.pdf`,完整文档结果 |
| 前缀垃圾的 PDF | 同上 | 同上 | 同上 |
| 魔数表内的图片(JPEG/PNG/TIFF/BMP/GIF/WebP) | `image.jpg`(不变) | `image.jpg`(不变) | `image.jpg`,1 页结果 |
| 魔数表之外的可解码图片 | `image.jpg`(不变,服务按内容解码) | `image.jpg`(不变) | **本地 `RequestRejectedError`**(请改走 `parse_layout`;表内格式的扩充另议) |
| 无法识别的字节(HTML 页面、乱码、空) | `image.jpg`(不变,服务给幻觉文本 E13) | `image.jpg`(不变,服务 500) | **本地 `RequestRejectedError`,不发 HTTP** |
### Q3 文字内容从哪里来
| 方案 | 做法 | 权衡 |
| --- | --- | --- |
| **A(推荐)** | **只读 `_middle.json`**(已是 bbox/type/页码/阅读序的来源),从 `lines[].spans[]` 取 `content`/`html`;`.md` 原文单独取 | 单一来源,无对位、无类型映射、无版本漂移故障面;不受 `\$` 转义污染 |
| B | `_content_list.json` 取文字 + middle 取 bbox,按页内顺序对位并严格校验(本设计初稿方案) | 1:1 是上游构造性成立但**无契约**;漂移时整份 `ResultInvalid`,几百篇批处理会"一批全挂";文本含 `\$` 需反转义,且 title 的 level 等字段样本中无信息量(21/21 均为 1) |
| C | 只用 content_list、不要 bbox | 不满足 issue 的"每块含 bbox" |
### Q4 确定性坏输入(E5)的分类
| 方案 | 做法 | 权衡 |
| --- | --- | --- |
| **A(推荐)** | **只在 `parse_document` 路径**: ① 本地魔数预检(既非 PDF 也非已知图片魔数 → 本地 `RequestRejectedError`,零 HTTP); ② 500 + `Parsing failed:` 且含已知确定性标记 → `RequestRejectedError`(保留 `status_code=500`)。共享 `_classify_status` 默认行为字节级不变 | 既有 `parse_layout`/`recognize_text` 的行为零变更;未知 500 仍 Transient(安全方向) |
| B | 直接改共享 `_classify_status`,所有 OCR 方法受益 | 改变医院线对坏图的重试/熔断行为,属公共行为变更,应单独走设计 → **另开 issue**(待人类决定) |
| C | 不处理 | 批处理后果见 §6 |
### Q5 长文档超时
| 方案 | 做法 | 权衡 |
| --- | --- | --- |
| **A(推荐)** | 不加逐调用 HTTP 超时;文档线用**独立 scope**(如 `OCR_DOC`)指向同一服务 URL,自带 `TIMEOUT_S`/重试/熔断阈值;文档 + 测试钉死"无延迟判慢" | 与现有租约不变式一致 |
| B | `parse_document(timeout_s=…)` 逐调用超时 | `RateLimiter.try_acquire(source_key, est_tokens)`(`ports.py:137`)无 TTL 参数,租约长度是构造期常量,逐调用放大超时会让请求活过租约 → 并发配额被突破 |
## 4. 公共 API(冻结面,人类门审这里)
### 4.1 类型(`types.py`,全部 frozen dataclass)
| 类型 | 字段 | 说明 |
| --- | --- | --- |
| `OcrDocumentElement(OcrLayoutElement)` | 继承 `type/bbox/page_index`;新增 `text: str` | `text` 随 type: `text`/`title` → 纯文本(行内公式保留 `$…$`);`interline_equation` → 裸 LaTeX;`table` → HTML 表体;`image` → `""`;嵌套的非 body 子块(`*_caption`/`*_footnote`)作为**独立元素**输出,type 取子块类型 |
| `OcrDocumentResult` | `elements: list[OcrDocumentElement]`、`page_sizes: list[tuple[float,float]]`、`markdown: str` + 溯源五件 `source_name/usage/latency_ms/call_id/raw` + `call_stats=None` | **独立定义,不继承** `OcrLayoutResult`: 父类末尾 `call_stats` 带默认值,子类无默认字段 `markdown` 会在导入期 `TypeError` |
| `OcrDocumentTransportResult` | `elements`、`page_sizes`、`markdown`、`raw` | transport → client 内部产物 |
约束与理由:
| 点 | 决定 |
| --- | --- |
| 为何继承 | 文档元素"是"版面元素 + 内容;按 bbox 裁剪的下游函数可同时吃两种。代价: dataclass `__eq__` 要求同类,`OcrDocumentElement != OcrLayoutElement`;`OcrLayoutElement` docstring 加警示"追加带默认值字段前须评估子类" |
| 为何不加 `level`/`captions`/`footnotes` | 样本中 21 个 title 全是 level 1(无信息量);caption/footnote 在全部样本里恒为空——为从未见过的路径加公共字段不可验证。嵌套子块独立成元素的方案零新增字段,且服务将来开始关联 caption 时不丢信息 |
| `markdown` | 整篇产物,属 result 不属元素。**含悬空图片引用** `![](images/<sha>.jpg)`(库不外传图片),docstring 注明 |
| 阅读序 | 与服务 `para_blocks` 顺序一致,页内按页序;嵌套子块紧随其父块 |
### 4.2 端口(`ports.py`)
| 端口 | 签名 | 说明 |
| --- | --- | --- |
| `OcrDocumentPort`(新,`runtime_checkable`) | `async def parse_document(self, document: bytes) -> OcrDocumentResult` | 对应 `/parse` 整份文档 |
| `OcrDocumentTransport`(新,`runtime_checkable`) | `async def parse_document(self, *, document: bytes, source: SourceConfig, call_id: str) -> OcrDocumentTransportResult` | **不往 `OcrTransport` 加成员**(会破坏全部现有实现的 `isinstance`);`MonkeyOcrTransport` 同时实现两者 |
`OcrClient.parse_document(document, *, session_id=None, parent_call_id=None, tenant_id=None, meta=None, call_deadline_s=None)`: 签名与 `parse_layout` 对称。入口校验先于 `_CallContext`(同 `_call` 既有顺序): `bytes` 非空、`validate_caller_dimensions`、`ensure_call_deadline`、**transport 须满足 `OcrDocumentTransport`,否则 `TypeError`**(装配错误当场报)。
### 4.3 其它公共面
| 项 | 变更 |
| --- | --- |
| `CallOperation` / `CALL_OPERATIONS`(`types.py:279,285`) | 新增 `"parse_document"`。`CALL_OPERATIONS` 目前**无任何读者**,新增测试钉死 `get_args(CallOperation) == CALL_OPERATIONS` |
| 包根导出 | `OcrDocumentElement`、`OcrDocumentResult` |
| 遥测 | 同一张 `llm_calls`,`operation="parse_document"`;占位仍为 `<ocr:document image_bytes=N>`(**不改字面**: 改名会破坏既有分组与 `test_telemetry.py` 对格式的锁定);response 摘要 `<document elements=N pages=M md_chars=K>`,**永不写入正文**;无新列、无 DDL 变更 |
| 配置 | **无新键**。文档线独立 scope 是部署方式,不是新配置(`.env.example` 加示例段) |
## 5. Transport 行为(`transports/monkey_ocr.py`)
| 关切 | 决策 |
| --- | --- |
| 上传标签 | `_files()` 改为 `_upload_part(data)`,按 §3 Q2 的固定顺序嗅探: PDF → `("doc.pdf", data, "application/pdf")`,其余 → 原样 `("image.jpg", data, "image/jpeg")`。三个方法共用。包内文件名前缀由 `image_` 变 `doc_`,解包靠后缀 `endswith("_middle.json")`(`monkey_ocr.py:179`),不受影响 |
| 本地预检(仅 document) | 嗅探结果为"未知"(HTML 付费墙页面、乱码、空串)→ `RequestRejectedError`(**不带 status_code**,服务未响应),不发 HTTP。已知图片魔数: JPEG `FFD8FF`、PNG、TIFF(`II*\0`/`MM\0*`)、BMP(`BM`)、GIF、WebP(`RIFF…WEBP`);魔数表之外的可解码图片格式在 document 路径会被拒,需要时由调用方走 `parse_layout` |
| 两段协议 | 与 `parse_layout` 同: POST `/parse` → 校验 `success is True` + `download_url` → GET → ZIP。document 路径的 GET 改**流式读取并累计字节**,整包上限 256 MiB(模块常量),超限 → `ResultInvalidError`;HTTP 状态错误须先 `aread()` 再 `raise_for_status()`,保持"响应体摘要随所有分支"的 issue #10 口径 |
| 解析卸载 | ZIP 解包 + JSON 解析 + 结构校验是 bytes→结果的**纯函数**,在 `asyncio.to_thread` 中执行: 同步解析大对象会占住事件循环,使取消与 `call_deadline_s`(基于 `asyncio.timeout`,`deadline.py:69-79`)无法及时生效。取消后线程自然收尾、结果丢弃。`parse_layout` 不改 |
| 外部数据异常边界 | 纯函数内**统一**把外部字节引起的已知异常转 `ResultInvalidError`: 函数级 `ValueError`(含 `JSONDecodeError`/`UnicodeDecodeError`)、`TypeError`、`KeyError`、`OverflowError`(巨大整数转 float,实测 `float(10**400)` 抛它)、`RecursionError`(深嵌套 JSON)、`zipfile.BadZipFile`/`LargeZipFile`;**仅包在 `archive.open/read` 外**的窄 `try`: `RuntimeError`(标准库对加密成员抛它)、`NotImplementedError`(未知压缩方法)、`zlib.error`、`EOFError`。取消(`CancelledError`)不在其内,原样穿透。理由: `ocr.py:303-321` 的 `_call` 只接 `PolyGatewayError`,漏出的原生异常既不写终态遥测行,又会让持有的半开探针不被归还 |
| ZIP 成员与体积 | 读 `*_middle.json`(取第一个,同 layout);`.md` 按 `middle 名去掉 "_middle.json" + ".md"` 精确配对(不取"第一个 .md");缺失 → `ResultInvalidError`。读取前检查 `ZipInfo.file_size`: middle ≤ 64 MiB、md ≤ 16 MiB,超限 → `ResultInvalidError`。**不用 `extractall`**,不解出 `images/`;成员数由整包上限间接约束。仅 document 路径;`parse_layout` 不改(医院线零变更) |
| 文字提取 | 块内 `lines[].spans[]`: 取 `content`;`inline_equation` 类型 span 包 `$…$`(上游惯例,**样本中从未出现**);行内 span 按 `""` 拼、行间按 `"\n"` 拼。**样本全是 1 行 1 span**,多行/多 span 路径无真实样本,仅构造 fixture 测试,文档标"未经实测" |
| 其它页内容 | `discarded_blocks` 忽略(E12: 服务端已丢弃脚注/页眉页脚/页码,span 内容全空,库无物可取);`need_drop` 不读取、不失败(该流水线硬编码为 False) |
| 嵌套子块 | 默认部署不产生(E12);仅作最小防御: 非 `*_body` 的嵌套子块若有文字,作为独立元素紧随父块输出,**不静默丢弃**。该路径无真实样本,仅构造 fixture 测试 |
| `raw` | `{"success": True, "download_url": …}`,与 `parse_layout` 同形 |
### 5.1 结构契约(缺失 / 空值 / 类型错误 / 未知类型逐项裁决)
架构要求严格区分合法"无内容"与调用失败(`ARCHITECTURE.md:427,672`)。下表是实现与测试的唯一依据。
| 情形 | 处理 |
| --- | --- |
| `pdf_info` 缺失或非 list | `ResultInvalidError` |
| `pdf_info == []`(零页) | `ResultInvalidError`: 成功解析的文档至少 1 页(与 layout 路径"elements 空合法"不同,差异写入 docstring) |
| 页非 dict / `page_size` 非 2 个正有限数 | `ResultInvalidError`(复用 `_parse_page_size`) |
| `page_idx` 非 int、为 bool、为负、或 ≠ 页在列表中的位置 | `ResultInvalidError`(layout 路径的"回退枚举"会使 `page_sizes` 按 `page_index` 索引的契约静默错位) |
| `para_blocks` 缺失 | 合法空页(空白页 / 无可识别块);非 list → `ResultInvalidError` |
| 块非 dict、`type` 缺失/非 str/空白 | `ResultInvalidError` |
| `bbox` | 须为长度 4 的有限数值且 `x1 ≤ x2`、`y1 ≤ y2`(允许零宽/零高,**不做**整数化退化校验——那条专为 CHS shim 的 `int()` 裁剪);反序/非有限 → `ResultInvalidError`。坐标系与 `page_sizes` 同为服务原生坐标,页码从 0 起;越界框不检查、不裁剪(图内语义归调用方,`ARCHITECTURE.md:250`) |
| 含 `lines` 的块(text/title/interline_equation/未知类型) | `lines` 须 list,每行 dict 且 `spans` 为 list,每 span 为 dict 且 `content` 为 str;违反 → `ResultInvalidError`;`lines == []` → `text=""` |
| table | 嵌套 `table_body` 的 span 取 `html`,无则取 `latex`(上游有该分支);二者皆无 → `ResultInvalidError`(表格无内容不静默为空串) |
| image | 嵌套 `image_body` → `text=""` |
| 未知类型且既无 `lines` 也无 `blocks` | `text=""`,`type` 原样保留(开放字符串) |
| `.md` | 成员缺失 → `ResultInvalidError`;按 `utf-8-sig` 解码(吞 BOM),失败 → `ResultInvalidError`;空串合法 |
## 6. 错误分类与批处理后果
| 失败 | 分类 | 治理结果 |
| --- | --- | --- |
| 上传前: 非 bytes/空 | `TypeError`/`ValueError` | 统计边界外(同 `_call` 既有) |
| 嗅探为"未知"(§5) | `RequestRejectedError`(无 status) | 不重试、不换源;仅归还探针,**不计熔断** |
| 500 + `Parsing failed:` + 已知标记 | `RequestRejectedError`(`status_code=500`) | 不重试;熔断**记成功**且不计窗口(`_gate_on_terminal`: `exc.status_code is not None`);不喂选源器 |
| 429 | `TransientError`(`status_code=429`) | 照扣重试预算、喂选源器失败; **熔断两通道均不计**(`memory/breaker.py:235-244`: `rate_limited` 是背压不是故障) |
| 其余 5xx、超时、网络错误 | `TransientError` | 重试 + 计熔断(现状) |
| `CallDeadlineExceeded`(整体期限到期) / 外部取消 | 专用异常 / `CancelledError` | **不是**源故障: 不计熔断; 在飞探针归还; 终态遥测行照写(`ocr.py:303-321`)。期限不会被混入 `TransientError` |
| 401/403 | `SourceDeadError` | 现状 |
| 其余 4xx、`success!=true`(附 200) | `RequestRejectedError` | 现状 |
| ZIP 坏 / 加密或未知压缩成员 / middle 或 md 缺失 / 超限 / 结构异常(§5.1)/ `page_idx` 错位 / 零页 | `ResultInvalidError` | 不重试;熔断记成功不计窗口(`_gate_on_terminal`: `ocr.py:493-499`) |
已知标记(模块常量,小写子串匹配,取 E5 实测): `failed to open stream`、`cannot open empty stream`、`cannot save with zero pages`、`is no pdf`。**不含** `unsupported file type`: 嗅探后它只可能由服务契约变化或我方标签 bug 引起,应响亮失败并计熔断。`MonkeyOcrTransport` 构造参数可覆盖该元组,不新增 `SourceConfig` 字段或环境变量键。
**为什么 Q4 不能不做**(读代码确认的机制链): ① 熔断按 `source_name` 计数(内存 `memory/breaker.py:56-61`;Redis 键 `pgw:gate:{scope}:{source}`,`backends/redis/breaker.py:308-311`);② 有效阈值 = `max(配置值, 源并发×2)`(`config.py:503-508`);③ 连续失败通道仅在**窗口无充分健康证据**时开路——"充分健康证据"= 窗口样本 ≥ `min_calls`(默认 10)**且**失败率 < `fail_rate`(默认 0.6)(`memory/breaker.py:84-90`、开路判据 `:252`),故样本不足 **或** 失败率 ≥ 0.6 时连败即开路。每份文档约 15 秒、并发 2,窗口凑不够 10 个样本,抑制不生效;④ 一份坏 PDF 重试 `max_attempts` 次即可在低并发配置下开路,冷却内同 scope 的好文档被 `fail_fast` 拒绝。影响范围是**同 scope**: 默认 memory 后端是进程内,只有 Redis 熔断后端加相同 scope 才跨进程(是否波及医院线取决于部署)。⑤ 下游语义: `migrations/chsanalyzer.md:72` 记录 CHSAnalyzer 把库侧"服务不可用"一族按 arq 延期重投且不耗业务失败预算——**若 CHSAnalyzer3 沿用,永久损坏的 PDF 会被无限延期。此点未向下游证实,需人类确认。**
白名单的脆弱性(诚实声明): 标记是 PyMuPDF 的界面文案,随服务版本漂移; 服务把所有异常都包成 `Parsing failed:`,若把磁盘满等真故障也包成同样文案会被误判为坏输入。**误判代价是"该文档失败而非延期"(下游重跑失败集合即可),反向代价是熔断误开**——取前者。
## 7. 长文档与资源(issue 要求 4)
| 结论 | 依据 |
| --- | --- |
| 默认值与熔断**不会把长文档判为慢** | 无延迟型判定(§1 E9);选源器只数成败(`sources.py:61-64`);`StallClock` 只计非生产性等待(`ocr.py:343-344`) |
| 把"慢"变成失败的路径有两条,性质不同 | ① httpx 超时: `monkey_ocr.py:63-68` → `TransientError`(`__cause__` 为 `TimeoutException`)→ 重试 + 计熔断; ② `call_deadline_s` 到期 → `CallDeadlineExceeded`,**不计熔断**(§6) |
| **`timeout_s` 不是总耗时上界**(Codex 审查纠正) | `httpx.Timeout(source.timeout_s)` 是**逐次网络操作**的超时(`httpcore` 每次读单独计时): POST 读超时≈服务处理时长上限(服务处理期间无字节),但 GET 下载只要持续有分块到达,总时长可超过任意多个 `timeout_s`。故初稿"最坏约 2×`timeout_s`"不成立,**不能据此推出租约安全** |
| 租约与探针租约的真实关系 | `config.py:338-345` 只守卫 `timeout_s ≤ lease_ttl_s`(默认 1500);限流租约到期即被移除(`memory/limiter.py:98-103`),探针租约到期可被接管(`memory/breaker.py:161-163`,派生 TTL 见 `config.py:509-518`)。**硬保证只能来自 `call_deadline_s`**: 文档线应配 `call_deadline_s ≤ lease_ttl_s`,使任何一次尝试都不会活过租约; 守卫改造(跨 scope 配置语义)**不在本次**,另列 follow-up |
| 配置口径(写入 `.env.example` 与 README) | 文档线独立 scope + `MAX_CONCURRENCY=2`(满足"并发不超过 2";sync-only 见下行)+ `RETRY__MAX_ATTEMPTS=2` + `timeout_s ≥ 最大页数×4s×余量`(实测 1.5–4 s/页;共用 GPU 争用再乘 3–5 倍)+ **`call_deadline_s ≤ lease_ttl_s` 给单份封顶**。有效熔断阈值 `max(配置, 并发×2)=4` > 单份坏文档的重试次数 |
| 重试放大 | POST 成功(服务已算完)而 GET 失败时,重试**重新 POST 整份文档重算**; 超时重试同理,且服务端是否因断连取消计算无证据——假定不取消 |
| 无页数预检 | 库不依赖 PDF 库,发送前不知页数; 300 页文档会占满超时被重投。页数过滤是下游职责 |
| 内存 | document 路径 GET 改流式并累计字节,**整包上限 256 MiB**(§5);只解必要成员,成员再受声明大小上限约束。**诚实标注(验证者 M-4)**: 合并分块 `b"".join` 与随后的 `BytesIO` 会让瞬时峰值接近 2 倍上限,README 已写明预留。httpx 非流式 `get` 会先 `aread()` 整包(`httpx/_client.py:1635-1637`),这是改流式的原因; `parse_layout` 不改 |
| 服务端磁盘 | 每次调用在服务 `/app/tmp` 与 `/static` 留产物,几百篇约几百 MB 到 GB 级——**告知服务运维**(库不管理) |
| 并发上限的进程边界 | `MAX_CONCURRENCY` 默认进程内; 多 worker 共享上限需 `PGW_LIMITER_BACKEND=redis` 且同 scope |
| **服务端串行与队头阻塞(E14)** | 若线上为 sync-only: ① 每实例 `/parse` 全局串行,"并发 2"不提速,第二份文档在服务端排队 → `timeout_s` 须覆盖"排队+处理"≈ 并发数×单份耗时×余量; ② 一份 15 页文档(按 1.5–4 s/页估算约 22–60 秒)的队头阻塞可能把医院线(`table_locator`)请求推到它们自己的 `timeout_s`,触发**它们**的 Transient 与熔断——"我们的长文档误伤他们"比"他们的判定误伤我们"更现实; ③ 7867 实例与 embedding 服务同卡(部署记录),重负载互相影响,不在库治理范围。**须向运维确认 backend**(启动日志 `model_manager.py:37-38`)。指引: sync-only 时文档线每实例并发 1、错峰运行、页数过滤在调用方 |
| 服务限制(告知下游) | 脚注/页眉页脚/页码在服务端即被丢弃(E12): 论文表格脚注的显著性与缩写说明不会出现在结果里; `markdown`/`elements` 均无此内容。库不能也不应补偿 |
## 8. 旧版行为审计
| 行为 | 裁决 |
| --- | --- |
| `_files()` 固定 `image.jpg` | **替换**: 嗅探为 PDF → `doc.pdf`; 其余**保留**(字节级不变) |
| `parse_layout` 收到 PDF | **有意改变上传分支**(`ImageDataset` → `PymuDocDataset`,E10)。差分证据: 文本型与位图型 2 页 PDF 结果逐字相同(E3); **旋转页、带批注页未测,不构成保证**。该路径无已知消费者(医院线只传图片,CHSAnalyzer3 走新方法),故列 H6 由人类批准,而不是声称"零风险" |
| `recognize_text` 收到 PDF | **修复**: 静默错误结果(E4)变为整份全文; 文档注明"不承诺 PDF 语义,请用 `parse_document`" |
| `parse_layout` 的 bbox 整数化退化校验 / `page_idx` 回退 / 无 ZIP 体积上限 | **保留不动**(医院线零变更); document 路径各自更严/更宽,差异写入 docstring |
| `_classify_status` 共享分类 | **保留**(默认参数不变) |
| `OcrTransport` 协议 | **保留**,不加成员 |
| `check_health`、重试/选源/限流/熔断/遥测编排 | **保留**: `parse_document` 复用同一 `_call/_run/_attempt` 循环,只扩 `_OcrKind` 与 `_invoke` 分派 |
## 9. 非功能维度
| 维度 | 回答 |
| --- | --- |
| 并发与取消 | 无新共享可变状态; `CancelledError` 路径与 `parse_layout` 一致(退避 sleep/限流等待/两段 HTTP 均可取消,permit 在 `finally` settle+release,探针取消归还); **新增**的 ZIP 解析在 `to_thread` 中,取消时立即返回(线程自然收尾、结果丢弃),不会像同步解析那样占住事件循环使取消/期限失效。`call_deadline_s` 到期会取消在飞请求,服务端是否继续计算未知 |
| 降级方向 | 准入阶段(选源/限流/熔断)后端故障 → 报错不放行(铁律,复用既有 `SourceAdmission`); 已发出请求之后的记账写回(`_record_quietly`,`ocr.py:511-517`)与遥测写失败 → warning 降级。**不能**把"后端故障一律上抛"理解为所有阶段 |
| 幂等与重复 | 只读幂等,重放安全; 代价见 §7"重试放大" |
| 持久化与原子性 | 库内无持久化; ZIP 解包在内存(`BytesIO`),不落盘 |
| 零业务假设 | 无医学/论文词汇进入库代码与 fixtures; `type` 仍为开放字符串 |
## 10. 测试策略
fixtures 一律**合成**(`tests/unit/test_monkey_ocr.py` 的 `_zip_bytes`/`_page` 风格上扩一个 document builder),**不提交任何真实 ZIP**(第三方内容许可不明; 医疗数据不入库,同 M3 设计 §11)。
| 层 | 内容(每条先红后绿) |
| --- | --- |
| unit/transport | 嗅探: 纯 PDF、前缀垃圾的 PDF、起始为图片魔数但内含 `%PDF-` 字样的图片(须判图片)→ 各自标签,非 PDF 逐字 `image.jpg`/`image/jpeg`,**三个方法各钉一次**(`test_multipart_shape` 现有断言保持成立); 文档解析: title/text(含 `$X$`)/interline_equation/带 nested `table_body` 的 table/带 `image_body` 的 image 的 text 取值; 垃圾 `content_list.json` 与 `images/x.jpg` 并存时输出与其无关; nested caption/footnote 成独立元素(标未经实测); `inline_equation` 包 `$`; 多行/多 span 拼接; `markdown` 精确配对与缺失; 体积上限; §5.1 契约表**逐行一条用例**(零页、`page_idx` 为 bool/负/错位、`para_blocks` 缺失=空页、非 list、块无 type、反序/非有限 bbox、零宽 bbox 合法、`lines` 各层类型错误、table 无 html/latex、未知类型、`.md` 缺失/非法编码/BOM/空串); `discarded_blocks` 不进结果 |
| unit/资源与异常边界 | 加密成员、未知压缩方法、`10**400` 整数、深嵌套 JSON、CRC 损坏 → 全部 `ResultInvalidError`(**不漏出原生异常**),并断言经 `OcrClient` 后终态遥测行已写、半开探针已归还; 整包流式累计超限(大图片包 + 小 middle/md)→ ResultInvalid; 声明大小超限成员 → ResultInvalid; 解析期间取消 → 立即 `CancelledError` 穿透(用阻塞式解析桩验证事件循环未被占住) |
| unit/分类 | 魔数预检(HTML 页面、空串、未知魔数); 四个已知标记 → `RequestRejectedError(status_code=500)`; 未知 500 仍 Transient; `unsupported file type` 仍 Transient; 默认 `_classify_status` 行为不变(现有 `test_http_status` 保持) |
| unit/client | 桩 transport 走完整治理循环; `isinstance` 缺 `OcrDocumentTransport` → `TypeError`; **无延迟判慢**: 在成功尝试内推进远大于 `timeout_s` 的时钟,断言成功、熔断 CLOSED、`fails==0`(**含"熔断已有失败计数"与半开探针两种前置状态**,不只测初始 CLOSED); 对照: 超时 Transient → 计失败 → 重试; **持续慢速下载**(每个分块都在 read 超时内、总时长超过 `call_deadline_s`)→ `CallDeadlineExceeded` 而非 Transient,且不计熔断; 429 → 照扣重试预算但熔断窗口不变; ResultInvalid/本地预检不计熔断窗口; 取消穿透且探针归还; 遥测行 `operation="parse_document"`、占位字面量、摘要不含正文; `_summarize` 三类结果各一条(新结果必须显式区分,防止静默套用 layout 摘要而漏记 `md_chars`); 终态行; 调用方维度(`tenant_id`/`meta`)与 `reasoning_applies=False` |
| unit/契约 | `get_args(CallOperation) == CALL_OPERATIONS`; `test_ports.py` 新增 Protocol 的 `runtime_checkable` 用例,并断言 `OcrTransport` 成员集合**未变** |
| integration(`slow`,opt-in,串行) | 手写最小合法 PDF(无第三方内容)打真实服务: `parse_document` 返回 ≥1 页、元素均有 `text` 类型; 同一 PDF 的 `recognize_text` 不再返回 E4 的幻觉文本; 损坏 PDF → `RequestRejectedError` 且熔断仍 CLOSED。成败取决于外部服务当下状态,按 CLAUDE.md §4.6 标 `slow`,不进日常套件 |
## 11. 落地时需同步的位置(grep 全引用面所得)
| 类别 | 位置 |
| --- | --- |
| 代码 | `types.py`(Literal/常量/新类型)、`ports.py`、`ocr.py`(`_OcrKind`/`_AttemptOutcome`/`_invoke`/`_emit`/`_summarize` 签名)、`transports/monkey_ocr.py`、`__init__.py`(导出 + `__version__`)、`pyproject.toml` |
| 校验工具 | `tools/check_wiki_alignment.py:37-46`(`_SIGNATURE_TARGETS` 加 `OcrClient.parse_document`) |
| wiki 注册 | `research-wiki/schemas/llm-calls.md`(operation 取值)、`metrics/ocr-call-success.md`、`metrics/call-telemetry-coverage.md`(无推理入口列表) |
| 文档 | `README.md`(方法数、能力表、安装版本约束)、`CHANGELOG.md`、`.env.example`(文档线 scope 示例)、`ARCHITECTURE.md` §5.1/§7.10/D9 注记、M3 设计 §4 勘误(指向本设计 E3/E4)、`migrations/chsanalyzer.md`(CHSAnalyzer3 新消费路径) |
| 测试 | `test_telemetry.py` 的 operation 参数化、`test_ports.py`、`test_monkey_ocr.py`、`test_ocr_client.py`、`tests/integration/test_monkey_ocr_live.py` |
| 无需迁移 | `llm_calls.operation` 列为 `TEXT` 无 CHECK(`telemetry/schema.py`),新值不需 DDL; 外部按 operation 分组的看板会自然多一组 |
CLAUDE.md §4.4.1 发布流程按其清单执行(README 在构建前改完; 1.3.11 与历史把新增公共方法放 patch 的惯例一致)。
## 12. 评审与待人类拍板
**已做**: Claude 自审; 一轮独立 subagent(全新上下文、只读)对 Q1-Q5 与遥测/引用面逐项论证,并核对本机 MonkeyOCR 源码(E10-E14,关键行已由我对源码与线上各复核一次)。**采纳其修正**: 弃初稿"content_list+middle 对位"(Q3,以 `\$` 污染 23/134 为据)、砍 `level/captions/footnotes`(Q1)、`OcrDocumentTransport` 另起 Protocol 而非扩 `OcrTransport`(§4.2)、document 路径的 `page_idx` 严格与 bbox 宽松(§5)、确定性 500 只做新路径窄化(Q4)、ZIP 体积上限、`_summarize` 改法。**更正我方事实**: 初稿称"结果包无 `images/`",实为含图文档才有(E6)。**Codex 独立审**: 一轮已完成,初审结论"否——先修正再审批";逐条核验后修订,见文末"审查记录"。
**需要人类决定**:
| # | 决策 | 推荐 |
| --- | --- | --- |
| H1 | §4 公共面(新方法 + 新 Protocol + 继承式元素、`text` 唯一新增字段、嵌套子块独立成元素) | 批准 |
| H2 | 既有端点的分类/校验问题: 本设计只在新路径窄化; 以下两项属**医院线既有行为变更**,另开 issue 供评估: (a) 共享 `_classify_status` 对确定性坏输入 500 仍归 Transient; (b) `recognize_text` 不校验 `success`(服务内部异常时返回 200+`success:false`+空文本,被当"合法无文字"且熔断记成功,E13,源码推断未线上复现) | 同意,并授权我建这两个 issue |
| H5 | **需求替换**: issue #34 原文提议 `parse_document(data, *, filename 或 media_type)` 由调用方声明类型; 本设计改为库按内容嗅探、**不提供声明参数**(理由见 §3 Q2: 冗余信息、笔误会变熔断事件、前缀垃圾 PDF 已被嗅探覆盖) | 批准替换; 若你更想要显式参数,代价是新增公共参数 + 与嗅探结果不符时的拒绝规则 |
| H6 | **有意改变既有方法的上传分支**: `parse_layout(PDF)` 与 `recognize_text(PDF)` 的 PDF 将走 `PymuDocDataset`/`doc.pdf`(前者结果在已测样本上逐字相同,旋转/批注页未测;后者是修复 E4) | 批准 |
| H3 | CHSAnalyzer3 是否把"服务不可用"一族按延期重投处理(§6 ⑤)——决定 `RequestRejectedError` 路径是否必要地"关键" | 请确认; 不影响实现,影响下游使用指引 |
| H4 | 实现完成后的发布(合并 main、tag、构建、上传 registry、建 Release、关闭 #34)**逐步等你明确指令**,不随实现自动执行 | 同意 |
## 审查记录
Codex 一轮独立只读审查(上游无法读取 Gitea issue,需求判断基于设计转述)。结论: **否,先修正**。逐条核验与处置:
| # | Codex 意见 | 核验 | 处置 |
| --- | --- | --- | --- |
| 1 阻断 | "2×`timeout_s`"不是总耗时上界,租约安全结论失效 | **属实**: httpcore 逐次读计时,GET 持续分块可超任意多个 `timeout_s`; 租约/探针租约到期语义见 `memory/limiter.py:98-103`、`memory/breaker.py:161-163` | §7 删除\"2×即安全\",改为硬保证只来自 `call_deadline_s ≤ lease_ttl_s`; 测试加慢速持续下载 |
| 2 高 | 调用方声明类型被自动嗅探替换,理由与 Q4 自洽性不足; 前缀垃圾 PDF 描述矛盾; \"1024 字节\"依据未核 | **部分属实**: 替换确属需求变更,须显式批准; 前缀垃圾 PDF 服务**实测可解析**,故改嗅探为\"图片魔数优先 → 前 1KB 内 `%PDF-`\",该场景不再被拒; 1024 字节来自 Adobe 阅读器的头部容忍惯例,不是 ISO 32000 正文,已改述为兼容性描述 | §3 Q2 重写 + 三方法结局表; 新增 H5 |
| 3 高 | `parse_layout(PDF)`\"零风险/不变\"与 E10 矛盾 | **属实**: 分支确实变了。补做位图型 PDF 差分(逐字相同); 旋转/批注页仍未测 | §8 改述\"有意改变\",新增 H6 |
| 4 高 | 成员大小限制不限整包内存; 同步解析使取消/期限失效 | **属实**(`httpx/_client.py:1635-1637` 非流式先 `aread()`; `deadline.py` 基于 `asyncio.timeout` 无法打断同步代码) | §5: document 路径流式 + 整包 256 MiB 上限 + `to_thread` 卸载; 测试加解析期间取消 |
| 5 中 | ZIP/结构异常边界缺失,加密成员/未知压缩/巨大整数会漏出原生异常 | **属实**: 实测 `float(10**400)` 抛 `OverflowError`,不在既有异常元组内; 漏出后 `_call` 不写终态行、探针不归还 | §5 明确异常转换边界,§10 加专项测试 |
| 6 中 | 逐块文字/空结果/未知类型/编码契约不完整 | **属实** | 新增 §5.1 结构契约表,§10 逐行一条用例 |
| 7 中 | bbox 放宽与\"继承使裁剪函数可通吃\"自相矛盾 | **属实** | 改为 `x1≤x2` 且 `y1≤y2`(允许零宽,去掉整数化退化),坐标系/页码约定写明 |
| 8 中 | 429 记账写错; 窗口条件写反; 遗漏 `call_deadline_s` | **全部属实**(`memory/breaker.py:84-90,235-244`): \"失败率<0.6\"是健康证据而非\"证据不足\" | §6/§7 订正,增 429 与期限两行 |
| 9 低 | `_summarize` 风险描述不准 | **属实**: 实际是漏记 `md_chars` | 措辞订正 |
| 引用核对 | `api/main.py:435-441` 行号偏移(分派在 `:164-170`); `config.py:503-507` 应为 `:508`; Redis 键缺 `pgw:` 前缀 | **属实** | 已订正 |
未采纳: 无(九条均成立,部分处置方式有调整)。**订正后未再送 Codex 复审**——修订量大,建议人类审阅时一并关注 §3 Q2、§5、§5.1、§7。
@@ -0,0 +1,109 @@
---
type: finding
node_id: finding:2026-09-10-136-call-deadline-validation
title: "1.3.6 可选调用期限与取消结算验证记录"
date: 2026-09-10
---
# 1.3.6 可选调用期限与取消结算验证记录
> 范围:分支 `feature/1.3.6-call-budgets` 上的 T1–T3 三个行为提交(`1ff83bb` / `9474c76` / `da77b12`)与 T4 文档提交。设计 `research-wiki/designs/2026-09-09-136-call-budgets-design.md`(人类已批准),计划 `research-wiki/plans/2026-09-10-136-call-deadline.md`。
> 本文件是**证据索引**:原始输出在 `tests/outputs/136/`(按纪律**不提交**),此处只记路径、命令、退出码与结论。
> 环境:conda 环境 `PolyGateway`,**Python 3.12.13**(`conda run -n PolyGateway python -V` 实测)。所有 pytest/lint 命令均**不接管道**,退出码直取。
> 版本号未 bump、未 tag、未发布——发布清单(CLAUDE.md §4.4.1)不在本轮范围。
## 1. 红绿证据索引
| 任务 | 阶段 | 证据文件 | 结果 |
| --- | --- | --- | --- |
| T1 取消结算 | 红/绿 | **未落盘**(见 §1.1 诚实说明) | 见 §1.1 |
| T1 真实 Redis | 绿(本轮在 HEAD `da77b12` 上复跑) | `tests/outputs/136/t4/redis_cross_connection.txt` | `8 passed`,`exit=0` |
| T2 期限 | 红(值域+形态) | `tests/outputs/136/t2/red_deadline.txt` | 收集期 `1 error`(`deadline.py` 缺席),`exit=2` |
| T2 期限 | 绿(`test_deadline.py`) | `tests/outputs/136/t2/green_deadline.txt` | `22 passed`,`exit=0` |
| T2 接线中途 | 绿 | `tests/outputs/136/t2/unit_contracts_midway.txt`、`unit_contracts_after_wiring.txt` | 各 `1560 passed, 17 skipped` |
| T2 入口冒烟 | 绿 | `tests/outputs/136/t2/entry_smoke.txt` | 四个方法签名含 `call_deadline_s`;到期异常 `has retry_after_s: False`,`exit=0` |
| T2 回归门 | 绿 | `tests/outputs/136/t2/final_unit_contracts.txt` | `1572 passed, 17 skipped`,`pytest_exit=0` |
| T2 lint | 红→绿 | `tests/outputs/136/t2/lint.txt`(`Found 3 errors`,`lint_exit=2`)→ `lint_final.txt`(`All checks passed!` + `Contracts: 1 kept, 0 broken.`,`lint_exit=0`) | 修后绿 |
| T2c 补测 | 红 pass1 | `tests/outputs/136/t2c/red_pass1_import_absent.txt` | `3 errors in 0.29s`(三个模块收集期 ImportError) |
| T2c 补测 | 红 pass2 | `tests/outputs/136/t2c/red_pass2_real_reasons.txt` + `red_method_and_reasons.txt` | `32 failed`;分布见 §1.2 |
| T2c 补测 | 绿 | `green_unit_after_hardening.txt`(`1530 passed`)、`green_client_recheck.txt`(`110 passed`,`EXIT=0`)、`green_recheck_client_embedding.txt`(`165 passed`)、`green_unit_contracts.txt`(`1592 passed, 17 skipped`)、`final_unit_contracts.txt`(`1606 passed, 17 skipped`) | 全绿 |
| T2c lint | 绿 | `tests/outputs/136/t2c/lint.txt` | `All checks passed!` + `Contracts: 1 kept, 0 broken.` |
| T3 `Retry-After` | 红 | `tests/outputs/136/t3/red.txt` | `6 failed, 8 passed, 129 deselected` |
| T3 `Retry-After` | 绿 | `green_file.txt`(`143 passed`)、`green_unit_contracts.txt`(`1606 passed, 17 skipped`) | 全绿 |
| T3 lint | 绿 | `tests/outputs/136/t3/lint.txt` | `All checks passed!` + `Contracts: 1 kept, 0 broken.` |
`tests/outputs/136/t2/lsp_noise_refutation.txt` 与 `t2c/lsp_noise_refutation.txt` 记录编辑器 LSP 报的 import/属性告警属环境噪声(`pydantic` 在 conda 环境可解析),不是代码缺陷。
### 1.1 T1 的证据形态(诚实说明)
T1(`1ff83bb`)的**先红后通过证据产生于当时的会话工具输出,未落盘为 `tests/outputs/136/t1/` 文件**。本文件不追认那次输出,只登记两项**当下可复核**的替代证据:
| 替代证据 | 内容 |
| --- | --- |
| 提交 `1ff83bb` 的 diff | 三个源文件 + 四个测试文件共 213 插入;测试侧含 S3/S7(取消结算按 `est`)、S5-dead(仍 `0`)、S8(`RuntimeError` 逃逸仍 `0`)、真实 usage 恰为 0 的成功仍 `0` 四组断言 |
| 本轮在 HEAD `da77b12` 上重跑真实 Redis | `pytest tests/integration/test_redis_cross_connection.py -q` → `8 passed`,`exit=0`(`tests/outputs/136/t4/redis_cross_connection.txt`) |
结论口径:**T1 的“红”只有会话内证据、无归档文件**;T1 的“绿”在当前 HEAD 上已被真实 Redis 复现证实。
### 1.2 T2c 两趟红证据的方法说明(诚实说明)
T2c 的红证据是在**基线 `1ff83bb`(T2 之前)**的 `git worktree --detach` 检出上取的,用 `PYTHONPATH=<worktree>/src` 覆盖 editable `.pth`(已实测 `polygateway` 加载自 worktree 且 `deadline.py` 缺席):
| 趟次 | 做法 | 结果 |
| --- | --- | --- |
| pass1 | 用例原样跑 | 三个测试模块**收集期** ImportError(`CallDeadlineExceeded` 不存在)→ `3 errors`。只证明符号缺席,**没有执行到函数体** |
| pass2 | 仅把缺失的**导入符号**替换成**本地占位异常类**(shim),让函数体真正跑起来 | `32 failed`:**27 条“参数/属性不存在”**(`chat()` 9、`embed()` 4、`GatewaySettings.__init__()` 3、`GatewaySettings.call_deadline_s` 属性 3、`recognize_text()` 2、`GatewayClient.__init__()` 2、`parse_layout()`/`OcrClient.__init__()`/`EmbeddingClient.__init__()`/`GatewayClient._call_deadline_s` 各 1)+ **5 条 `Failed: DID NOT RAISE ValueError`** |
**该 shim 是一次性本地脚手架,未提交、不在任何分支上**;它只替换导入符号,不改被测源码。故 pass2 的红**是针对预 T2 源码的真实失败原因分布**,而非构造错误——但读者需知这份红**无法从仓库检出复现**,只能从上表与 `red_method_and_reasons.txt` 复核。
## 2. 命令与退出码
| 命令(前缀均为 `conda run -n PolyGateway python -m`,lint 为 `make lint`) | 何时 | 结果 |
| --- | --- | --- |
| `pytest tests/unit/test_deadline.py -q` | T2 红 | `exit=2`(collection error,符合预期) |
| `pytest tests/unit/test_deadline.py -q` | T2 绿 | `22 passed`,`exit=0` |
| `pytest tests/unit tests/contracts -q` | T2 门 | `1572 passed, 17 skipped`,`exit=0` |
| `pytest tests/unit/test_client.py tests/unit/test_embedding.py tests/unit/test_ocr_client.py tests/unit/test_config.py -q -rf -k "…deadline…"` | T2c 红 | `32 failed`(基线 worktree,见 §1.2) |
| `pytest tests/unit tests/contracts -q` | T2c 门 | `1606 passed, 17 skipped`,`exit=0` |
| `pytest tests/unit/test_openai_compat.py -q -rf -k RetryAfterNonFinite` | T3 红 | `6 failed, 8 passed, 129 deselected` |
| `pytest tests/unit/test_openai_compat.py -q` | T3 绿 | `143 passed` |
| `pytest tests/unit tests/contracts -q` | T3 门 | `1606 passed, 17 skipped`,`exit=0` |
| `pytest tests/integration/test_redis_cross_connection.py -q` | T1/T4 复跑 | `8 passed`,`exit=0` |
| `make lint`(ruff + import-linter) | T2/T2c/T3 收尾 | `All checks passed!`;`Contracts: 1 kept, 0 broken.`(新 `polygateway.deadline` 层在内) |
T4(本次文档提交)**不含行为变更**,故未跑测试;仅新增上表最后一行的真实 Redis 复跑作为 §1.1 的替代证据。
## 3. 豁免索引(哪些门没跑,为什么,谁来兜)
| 未执行项 | 原因 | 兜底责任 |
| --- | --- | --- |
| `pytest -m slow`(真实网关 e2e、Redis 时间语义变体) | 成败取决于外部服务当下状态,默认被 `addopts = "-m 'not slow'"` 排除;计划 §5 明确本轮不跑 | **发布清单(CLAUDE.md §4.4.1)第 4 步**,合并 main 后统一执行 |
| `tests/e2e/` 四个文件 | 同上,本版零付费调用 | 同上 |
| 真实网关的期限行为实测 | 期限用例用真实事件循环时钟+假 transport 构造,余量 4–10 倍,不依赖网关 | 发布清单第 4 步的 e2e 顺带覆盖;**本轮无真实网关证据** |
| 模型能力矩阵复验 | 本版未触碰推理/能力表 | 不适用 |
| 跨 Python 版本验证 | 见 §4 残余三 | 未兜底,登记为残余 |
真实 Redis **不在豁免之列**:T1 已证、本轮在 HEAD 上复跑(`8 passed`),未以 memory 后端冒充。
## 4. 残余风险(三条,逐条复述设计 §12)
| # | 残余 | 诚实口径 |
| --- | --- | --- |
| 1 | 取消结算按 `est` 保留预扣 | 这是**保守选择,不是“上游已计费”的证明**。库无法知道端口已开始的那次调用是否真的产生了计费用量;方向定为宁多扣不空退(多扣只损失本窗口一点额度,空退会让已计费用量绕过闸门)。真实 usage 已知(含恰为 0)与已判 `SourceDead` 的 `0` 不被覆写;未分类异常逃逸仍按 `0`,属**已知残留,本版不动** |
| 2 | 清理期自抛 `TimeoutError` 时**无终态遥测行** | 与 1.3.5 的裸 `TimeoutError` 穿透**同一口径**(这条路径一直存在、一直没有终态行),本版没有让它变坏;**但期限把这条路径常态化了**——启用期限后触发清理的频率上升,其可达性随之上升。`with_call_deadline` 的局部变量身份比较保证这种 `TimeoutError` **不会**被误标成 `CallDeadlineExceeded`(`test_deadline.py` 有断言钉住) |
| 3 | 跨 Python 版本仅 3.12.13 有实证 | `asyncio.timeout` 的 `cm.expired()`/`uncancel()` 行为与清理期异常传播是探针在 **3.12.13 单一版本**上实测的;3.13+的行为未验证。库声明 3.12+,故这是**真实的验证缺口**,不是理论担忧 |
## 5. 发布清单第 4 步结果(2026-09-10,main `b0ab39e`)
| 门 | 结果 | 证据 |
| --- | --- | --- |
| `make lint`(合并后 main) | 通过(ruff + import-linter 1 kept 0 broken) | 会话内输出 |
| 全套件(unit+contracts+integration) | **1683 passed, 23 skipped, exit=0** | `tests/outputs/136/release/full-gate.log` + `.exit` |
| slow 交集子集(Redis 时间语义变体 + 真实网关冒烟) | **22 passed, exit=0**(21 分钟) | `tests/outputs/136/release/slow-scoped.log` + `.exit` |
| `test_thinking_live.py` 全模型能力矩阵 | **未跑——按 2026-09-10 人类批准的新规则豁免**:本版 diff 零触碰 `thinking.py`/能力注册表/相关 e2e 设施,复用 1.3.4/1.3.5 已登记矩阵证据;当晚多渠道额度耗尽,全矩阵只会产出超时链噪声。规则变更已写入 CLAUDE.md §4.4.1 第 4 步与 §4.6(提交 `b0ab39e`);被中途终止的全量尝试日志留存于 `slow-gate.log` 备查 | 本表 |
## 6. 未在本轮做的事
- 不实现 issue #24(长尾对冲):未获批准,代码与文档均无 hedge 机制,本版**不缓解 #24**。
- 不修 `ResultInvalid`/`RequestRejected` 已计费坏结果仍退全款:属另一族记账语义,未批准,登记待立 issue。
- 不 bump 版本号、不打 tag、不构建、不上传 registry、不同步 wiki——全部留给发布清单。
@@ -0,0 +1,80 @@
---
type: finding
node_id: finding:2026-09-10-24-hedged-requests-validation
title: "issue #24 长尾对冲请求与裸生成时间验证报告"
date: 2026-09-10
---
# issue #24 长尾对冲请求与裸生成时间验证报告
> 计划:`research-wiki/plans/2026-09-10-24-hedged-requests.md`;设计:`research-wiki/designs/2026-09-10-24-hedged-requests-design.md`(H1–H8 全数获批)。
> 分支 `feature/1.3.7-hedged-requests`;基线 main `166b286`(1.3.6);代码提交 `0a6d622`(T1)/`463eca3`(T2)/`adc0694`(T3),文档提交见本文件 git 历史。
> 所有命令在 `PolyGateway` conda 环境执行,未接管道(退出码不失真)。
## 1. 红绿证据索引
证据目录 `tests/outputs/137/{t1,t2,t3}`(不提交,本地留存);每份日志末尾带 `EXIT_CODE=` 行。
| 任务 | 相位 | 证据文件 | 退出码 | 结果与失败形态 |
| --- | --- | --- | --- | --- |
| T1 端口事件 | 红(批次 A) | `t1/red-batch-a.log` | 1 | 4 failed,143 deselected——失败均为 `TypeError`(端口签名无 `first_token_event` 必填 kw),非断言值不符 |
| T1 | 绿(批次 A) | `t1/green-batch-a.log` | 0 | 147 passed |
| T1 | 绿(unit 全套) | `t1/green-unit-full.log` | 0 | 1550 passed,既有断言一行未改 |
| T2 CallStats 三字段 | 红(批次 B–F) | `t2/red-batch-b-f.log` | 1 | 10 failed,373 deselected——字段不存在(`TypeError`/`AttributeError`)与计时字段恒 0 |
| T2 | 绿(批次 B–F) | `t2/green-batch-b-f.log` | 0 | 10 passed,373 deselected |
| T2 | 绿(unit+contracts) | `t2/green-unit-contracts.log` | 0 | 1621 passed,17 skipped |
| T3 对冲编排 | 红(编排+配置守卫,计划批次 G/H) | `t3/red_batches_d_h.txt` | 1 | 25 failed——14×RetryMW 缺 `hedge_after_s`、2×GatewayClient 缺参、4×GatewaySettings 缺属性、4×守卫未抛 ValueError、1×client 缺属性,均为未实现形态 |
| T3 | 绿(同上 25 用例) | `t3/green_batches_d_h_run1.txt` | 0 | 25 passed,2.55s |
| T3 | 绿(批次 I 默认关闭回归) | `t3/green_full_unit_contracts.txt` | 0 | **1646 passed** = 基线 1621 + 新增 25,17 skipped,48s;既有断言一行未改 |
| T3 | lint | `t3/lint.txt` | 0 | `make lint`(ruff --fix + import-linter)通过,Contracts: 1 kept,0 broken |
T3 各相位命令原文与退出码另见 `t3/commands.md`(该文件表头"批次 D–H"为执行批次流水号,对应计划 §5 的批次 G 对冲编排 + H 配置守卫,25 用例 = 15 编排 + 8 配置 + 2 client 入口校验)。
## 2. 批次与提交映射
| 提交 | 计划任务 | 覆盖批次(计划 §5) | 关键断言 |
| --- | --- | --- | --- |
| `0a6d622` | T1 端口事件(H2) | A | 流式首 token 置位事件;非流式永不置位;`None` 不观测行为不变;漏传必填 kw 即 `TypeError` |
| `463eca3` | T2 三字段与计时(H3+H8) | B–F | 三字段默认 0/0/False;chat 计时排除退避与准入;结构化重问取最后一轮;embedding 批次累加;OCR 单次;缓存命中恒 0 |
| `adc0694` | T3 对冲编排(H1/H4/H5/H6) | G、H、I | 触发两形态;异源排除;准入失败静默;赢家 settle 实际/输家 settle est;`hedge_cancelled` 标签;输家不喂熔断;`attempts==2` 无任务泄漏;外部取消两路穿透;deadline 切断对冲树;原路后发先至;两败计一次预算;两 429 免预算退 stall;混合失败计一次;配置守卫四路 |
## 3. 实测核对(文档承诺 vs 运行实测,本文件交付时复核)
| 承诺 | 核对方式 | 实测结果 |
| --- | --- | --- |
| `CallStats` 三字段默认值 | `CallStats(logical_call_id='x', attempts=1, total_latency_ms=5)` 仅旧三参构造 | `hedges=0`、`generation_ms=0`、`hedge_won=False`,构造不炸 |
| `hedge_won` 语义 | 现读 `middleware/retry.py:498-578` | 赢家裁定后 `hedge_won=winner is hedge`;两败轮次照登 `hedge_won=False` 且裸生成时间无归属不记;触发但准入失败不计 `hedges` |
| 两键 env 解析 | `GatewaySettings.from_env(env=...)` 注入两源 + `LLM__HEDGE__AFTER_S=8`/`MAX_EXTRA=2` | 解析出 `hedge_after_s=8.0`/`hedge_max_extra=2`,两源照常加载(3 段键未被当源字段);`MAX_EXTRA=2` 触发装配期 warning「v1 仅单路对冲生效」;不设两键时 `None`/`1` |
| 版本号不动 | `pyproject.toml` 与 `src/polygateway/__init__.py` | 两处均 `1.3.6`,本计划不 bump、不 tag、不发布 |
| 文档行号 | README/CHANGELOG/.env.example 接入点 | 均按交付时现读行号接入,未沿用计划旧行号 |
## 4. 豁免索引(未跑项与去向)
| 未跑项 | 理由 | 去向 |
| --- | --- | --- |
| `tests/e2e/`(slow) | 本计划不新增、不跑真实网关用例;`tests/e2e/conftest.py` 包装 transport 已同步转发 `first_token_event` | 发布清单第 4 步按 diff 交集选子集(本 diff 触及公开入口与 e2e 设施,e2e 冒烟届时在交集内) |
| Redis 契约/integration(slow) | T1–T3 未触碰限流 Lua、`Permit` 端口、`backends/**` 与 `tests/contracts/**`(计划 §2 不改清单);对冲结算复用既有 `settle_and_release` 路径,无新 Lua 行为可测 | 同按发布交集规则判断;diff 已触及 retry/限流结算路径,**Redis 时间语义变体届时在交集内**(计划 §5 末句已登记) |
| `test_thinking_live.py` 全模型矩阵 | 未动 `thinking.py`/能力注册表 | 不在交集,复用最近一次有效矩阵证据 |
## 5. 残余复述(设计 §10 与计划 §6 的已批准口径)
| 项 | 口径 |
| --- | --- |
| 非流式「挂起 vs 慢生成」 | 物理不可分(无中途信号),只能靠阈值取值(建议源 p50 数倍)与 `hedge_max_extra` 上限控制误对冲;分位数触发为未来扩展 |
| 输家取消止不住上游计费 | est 保留只是闸内保守记账,不是上游真实计量的计量;文档只写「可能已计费」,不写「浪费上限 = est」 |
| 竞速误贴标签 | 外部取消与对冲取消同时到达时,输家行可能误贴 `cancelled`/`hedge_cancelled`;两任务同消、记账方向一致(est 保留),不造成结算或熔断错误,可接受 |
| `hedge_max_extra` v1 单路 | 值域 [1,3] 接受,>1 仅装配期 warning,运行期恒单路(对冲任务恒传 `first_token_event=None`,不再触发梯次);若未来审定应为 `ValueError`,改 `check_hedge_assembly` 一处 + 批次 H 一条断言 |
| 挂起源不喂熔断的反向代价 | 持续挂起的源不会因对冲输家被熔断标记;「挂起率」只能靠 `hedge_cancelled` 遥测行统计(同 `logical_call_id` join 还原) |
## 6. 结论
## 7. 发布清单第 4 步结果(2026-09-10,main `a04d87e`)
| 门 | 结果 | 证据 |
| --- | --- | --- |
| `make lint`(合并后 main) | 通过(ruff + import-linter 1 kept 0 broken) | 会话内输出 |
| 全套件(unit+contracts+integration) | **1727 passed, 23 skipped, exit=0** | `tests/outputs/137/release/full-gate.log` + `.exit` |
| slow 交集子集(Redis 时间语义变体 + 真实网关冒烟;交集判据:本版动了重试/退避/取消路径与公开入口) | **22 passed, exit=0**(21 分钟) | `tests/outputs/137/release/slow-scoped.log` + `.exit` |
| `test_thinking_live.py` 全模型能力矩阵 | **未跑——按 2026-09-10 生效的交集规则豁免**:本版 diff 零触碰 `thinking.py`/能力注册表/相关 e2e 设施,复用 1.3.4/1.3.5 已登记矩阵证据 | 本表 |
T1–T3 全部行为变更具备先红后绿证据(§1),默认关闭回归门成立(1646 passed 且既有断言一行未改),lint 与 import-linter 契约通过。文档承诺经运行实测核对(§3)。版本 bump、tag、发布与 slow/e2e 交集子集**不在本计划内**,按 CLAUDE.md §4.4.1 另行执行。
@@ -0,0 +1,174 @@
# 流式中段卡顿对冲与"净生成时间"口径调研
> 调研日期: 2026-09-11。对应缺口: ①对冲触发判据只盯"首 token 未到",真实故障是"首 token 快(中位 1.6s)但流中段长时间沉默(中位 55s)";②`generation_ms` 把中段沉默包进墙钟,下游需要"净生成时间"。
## 〇、总览: 结论先行
### 主题 A: 流式中段卡顿(stream started but stalls mid-way)的对冲/恢复范式
| 范式名称 | 出处 | 与我们问题的对应关系 | 可直接借鉴的做法 |
| --- | --- | --- | --- |
| Hedged/Tied requests(原始定义) | Dean & Barroso, CACM 2013 | 论文语境是一元(unary)完整响应的 leaf 扇出,"first result"指整个响应,**未讨论增量/流式响应** | 延迟派发(等 p95 再发第二路)+ 首个结果到达即取消其余 + 额外负载可低至 ~2%;tied request 的跨副本取消协议 |
| 自适应对冲(TTFT 竞速) | llms.blog 生产架构文;bhope/hedge; InfoQ | LLM 网关语境把赢家裁定从"响应头"前移到"首 token";但该文断言"卡顿几乎只发生在排队/prefill"——**与我们实测(中段沉默中位 55s)相矛盾**,SGLang decode 饿死 issue 证明中段卡顿真实存在 | 按模型×渠道维护滚动 P90/P95 TTFT(DDSketch)作动态对冲阈值;token bucket 限制对冲放大率 |
| Envoy hedge policy | Envoy 官方文档 | 对冲赢家裁定在**响应头**("first response with acceptable headers"),一旦裁定即 commit;**响应体中段停顿不会重新对冲**,只能靠 stream idle timeout 兜底 | "判赢即 commit、不回头"的语义参考;`hedge_on_per_try_timeout` 命名;per_try_timeout 只在响应开始前有效(PR #10082) |
| gRPC hedging | gRPC A6 提案 + 官方指南 | hedgingDelay 以"收到非错误响应(头)"为节拍;收到 nonFatal 状态码立即补发下一路;server pushback(`grpc-retry-pushback-ms`)可推迟/禁止对冲;retryThrottling 用 token bucket 压对冲率 | `maxAttempts`/`hedgingDelay`/`nonFatalStatusCodes` 配置三元组;上限钳到 5;deadline 覆盖整个对冲链 |
| 流内不活跃看门狗(inactivity watchdog) | LiteLLM PR #29767、LangChain PR #36949(已合并) | **业界对中段沉默的主流应对是"看门狗→超时→抛错/重试",不是并发副本**;关键细节: 以**解析后的内容 chunk 间隔**计时,SSE keepalive 注释不重置计时器 | `stream_chunk_timeout`(LangChain, 默认 120s) / `LITELLM_STREAM_INACTIVITY_TIMEOUT_SECONDS`(LiteLLM 提案,默认关);首 chunk 不受看门狗约束(TTFT 由请求超时管) |
| 沉默检测→整路重试 | OpenAI Codex CLI issue #33051 | Responses 流静默 300s 后判定 `stream disconnected - retrying sampling request (1/5)`,是"中段沉默触发重试"的真实生产先例 | 沉默超阈值 → 归为 Transient → 走既有重试/换源链,而非保活等待 |
| serving 层 ITL 劣化成因 | vLLM disagg prefill 文档、vLLM PR #33743、SGLang #32549/#29879 | 中段卡顿的服务端根因: prefill 与 decode 同卡互踩、chunked prefill 调度饿死 decode(实测 decode 被压到 ~1 batch/24s) | 中段沉默是**结构性现象**而非异常个例,网关层必须有对策;观测指标参考 `sglang:decode_step_latency_seconds` |
### 主题 B: "净生成时间"(扣除沉默段)观测口径范式
| 范式名称 | 出处 | 与我们问题的对应关系 | 可直接借鉴的做法 |
| --- | --- | --- | --- |
| TTFT / ITL(=TPOT) / E2EL 标准定义 | NVIDIA NIM/AIPerf 指标文档、NVIDIA 技术博客 | E2EL = TTFT + Generation_time(首 token→末 token,剔除 `[DONE]`);ITL = (E2EL−TTFT)/(输出 token 数−1);**排队、prefill、网络全部计入,无任何沉默扣除** | 口径分层: 排队/prefill 归 TTFT,纯解码段单独计量;工具间定义不一致(TTFT 是否计入 ITL 均值)必须显式声明 |
| TPOT vs ITL 聚合权重 | Anyscale 指标文档 | 单请求 TPOT = ITL 均值;多请求聚合时 avg TPOT 按请求等权、avg ITL 按 token 加权 | 库内聚合统计要声明权重口径,否则跨实现不可比 |
| vLLM 阶段化计时 | vLLM metrics 设计文档 + per-request metrics 特性 | `request_queue_time / prefill_time / decode_time` 三段直方图;响应体 `metrics` 对象给 `generation_time_ms`(= decode time,排除排队与 prefill/TTFT) | **generation_time_ms 命名先例**(注意: vLLM 的 decode time 仍含引擎侧中段停顿,沉默扣除在服务端的口径也没人做) |
| off-CPU / wall-time 分解 | Brendan Gregg off-CPU analysis | 墙钟 = on-CPU + off-CPU(阻塞);方法论要求给阻塞段**归因**(stack/原因),而非只减总量 | 沉默段扣除必须同时记录沉默总量与归属(哪段、多长、几次),否则下游无法审计 |
| 沉默扣除实战先例 | pi-tps(pi 编辑器扩展) | **唯一直接对应"从生成速率里扣除沉默段"的实现**: gap ≥500ms 判为 stall,累计 stallMs/stallCount,从 streaming window 中扣除后算 TPS;连续停顿算一次事件;burst 交付/样本不足时输出 `—` 不硬算 | 阈值式扣除(减所有超阈值的段,而非只减最大一段);三分支防御(主口径/保守兜底/不可辨识时返回 null) |
| OpenRouter 供应商口径 | OpenRouter 路由/延迟文档 | 供应商对比口径: latency = TTFT(含网络+排队+prefill),throughput = 解码 TPS;Total = TTFT + tokens/TPS | "延迟=TTFT、速率=解码段"的二元口径是用户侧最广泛认知的形态 |
## 一、主题 A 详细发现
### A1. Tail at Scale 原始定义与流式后续工作
- 原始定义: "客户端先向其认为最合适的副本发送请求,短暂延迟后再发第二路;首个结果返回后取消其余在途请求"。额外负载可控: 等 95 分位预期延迟再发对冲,只惠及少量请求;更激进的对冲需要更快的取消(跨服务器取消的 tied requests)。([CACM 原文](https://cacm.acm.org/research/the-tail-at-scale/)、[作者 PDF](https://barroso.org/publications/TheTailAtScale.pdf))
- 论文场景是搜索 leaf 扇出的一元响应(各 leaf 返回 top-k 部分结果给聚合器),**"partial"在文中指分片结果,不是增量 token 流**;全文无 streaming/incremental 响应的对冲讨论。([semicolony 批注版](https://semicolony.dev/papers/tail-at-scale))
- 后续工作的共识补充: 高负载下对冲会级联放大(每个慢请求都对冲→负载↑→更慢),生产系统需要熔断/自适应阈值等反馈回路。([semicolony 批注版·批评节](https://semicolony.dev/papers/tail-at-scale))
- LLM 语境的适配文(llms.blog): 把对冲裁定前移到 TTFT 竞速,主张"首 token 一旦产出,prefill 已完成、decode 高度确定,卡顿几乎只发生在排队/prefill"。**此断言与我们实测不符**(首 token 中位 1.6s、中段沉默中位 55s),且被 serving 层证据推翻(见 A4),引用时须警惕。([llms.blog](https://www.llms.blog/posts/request-hedging-in-production-llm-serving-architecture-tail-latency-mitigation-and-cancellation-protocols))
- bhope/hedge(Go 库): 按 host 用 DDSketch 学延迟分布,主路超 p90 估计即发备路,token bucket 压对冲率(防故障期放大),p99 从 64ms→17ms(~74%),开销 ~9%;**明确支持流式负载(按 TTFT 而非响应头计量)**。([GitHub](https://github.com/bhope/hedge)、[InfoQ 解读](https://www.infoq.com/articles/adaptive-hedged-requests-p99-latency/))
### A2. 基础设施层: Envoy / gRPC / NGINX
- **Envoy**: 启用 hedge policy 后"竞速多个并发上游请求,把**首个带可接受响应头的响应**返回下游;由 retry policy 判定响应是否可接受"。当前对冲**只能由 per-try timeout 触发**(`hedge_on_per_try_timeout`: per-try 超时不取消原请求,直接补发)。([HTTP routing 文档](https://www.envoyproxy.io/docs/envoy/latest/intro/arch_overview/http/http_routing.html)、[HedgePolicy API](https://javadoc.io/static/io.envoyproxy.controlplane/api/1.0.37/io/envoyproxy/envoy/config/route/v3/HedgePolicy.html)、[PR #6228](https://github.com/envoyproxy/envoy/pull/6228))
- **Envoy 对"响应已开始但中途停顿"**: per_try_timeout "只在响应开始前有效"(PR #10082 明确允许全局 route timeout 禁用时配 per_try_timeout 给流式端点用)——即**判赢发生在响应头,中段停顿不会触发新的对冲**;流式响应的停顿只能靠 stream idle timeout 兜底(route timeout 默认 15s 与流式不兼容,须禁用改用 idle_timeout)。([PR #10082](https://github.com/envoyproxy/envoy/pull/10082)、[超时 FAQ](https://www.envoyproxy.io/docs/envoy/latest/faq/configuration/timeouts.html))
- **gRPC(A6)**: `hedgingPolicy = {maxAttempts, hedgingDelay, nonFatalStatusCodes}`;原请求立即发,每过 hedgingDelay 未收到非错误响应就补发一路;收到 nonFatal 状态码立即补发下一路;任一请求收到非错误响应→取消全部其余并返回该响应;fatal 状态码→取消全部并报错;server pushback(`grpc-retry-pushback-ms`,负值=禁止再发)可推迟/终止对冲;`retryThrottling{maxTokens, tokenRatio}` 按失败/成功比用 token bucket 压制对冲与重试;maxAttempts 客户端钳到 5;deadline 覆盖整条对冲链。([A6 提案](https://github.com/grpc/proposal/blob/master/A6-client-retries.md)、[官方指南](https://grpc.io/docs/guides/request-hedging/))
- gRPC 对冲对 server-streaming: listener 把 `onNext/onClose` 转发给所有在途对冲 RPC——流式场景赢家仍按"首个非错误响应(头)"裁定,**没有按消息流中段进度重新裁定的语义**。([A6 提案](https://github.com/grpc/proposal/blob/master/A6-client-retries.md))
- **NGINX**: 无对冲原语;`ngx_http_mirror_module` 只做多副本镜像且**忽略镜像响应**(fire-and-forget),不构成 FIRST_COMPLETED 竞速。([NGINX mirror 模块文档](https://nginx.org/en/docs/http/ngx_http_mirror_module.html))
### A3. LLM 网关/库层: 中段 stall 的真实实现与讨论
| 项目 | 机制 | 关键语义 | 来源 |
| --- | --- | --- | --- |
| LiteLLM(提案, PR 已 stale 关闭) | `_StreamInactivityGuard` + `LITELLM_STREAM_INACTIVITY_TIMEOUT_SECONDS`(默认关) | 以 `asyncio.wait_for` 约束 chunk 间隔;**首 chunk 不受看门狗约束**(TTFT 归请求 timeout, reasoning 模型首 token 慢);触发抛 `litellm.Timeout`(408) | [PR #29767](https://github.com/BerriAI/litellm/pull/29767) |
| LiteLLM(已发) | `ttft_timeout` 配置;`stream_timeout` 修为"整流墙钟上限" | `ttft_timeout`: 无首 token 超时即 `litellm.Timeout`(非流式内部提升为流式实现);httpx read timeout 被 keepalive 字节重置,**对"有字节无内容"的卡顿完全失效** | [config 文档](https://docs.litellm.ai/docs/proxy/config_settings)、[PR #31238](https://github.com/BerriAI/litellm/pull/31238) |
| LiteLLM(bug 证据) | 中段超时在 200 已发后抛在生成器内 | 被误记为成功(遥测语义教训);另有反向机制: 上游沉默时主动注入 SSE ping 保活(PR #36024, Anthropic 每 ~30s ping, Claude Code 20s 看门狗) | [Issue #29602](https://github.com/BerriAI/litellm/issues/29602)、[PR #36024](https://github.com/BerriAI/litellm/pull/36024) |
| LangChain(已合并, 1.2.0) | `stream_chunk_timeout`(默认 **120s**) + `LANGCHAIN_OPENAI_STREAM_CHUNK_TIMEOUT_S` | 计**解析后内容 chunk 的间隔**, SSE keepalive 不重置;触发抛 `StreamChunkTimeoutError`(TimeoutError 子类,携带 timeout_s/model/chunks_received 结构化字段);配套 TCP keepalive/`TCP_USER_TIMEOUT` socket 选项 | [PR #36949](https://github.com/langchain-ai/langchain/pull/36949)、[API 参考](https://reference.langchain.com/python/langchain-openai/chat_models/base/BaseChatOpenAI/stream_chunk_timeout) |
| LangChain JS(诉求) | 用户要"不活跃超时"而非"总时长超时" | 总时长超时误杀长流;间隔计时才是正确语义 | [langchainjs #9088](https://github.com/langchain-ai/langchainjs/issues/9088) |
| OpenAI Codex CLI | 流静默检测 + 整路重试 | 静默 300s 判 `stream disconnected`,重试 `retrying sampling request (1/5)`;用户报告 5–10 分钟级卡顿 | [issue #33051](https://github.com/openai/codex/issues/33051) |
| Portkey | request_timeout 曾用 `AbortSignal.timeout()` | bug: 流已开始仍被总超时杀掉;修复为收到首个响应即清定时器——"总时长 vs 不活跃"同一教训 | [Issue #471](https://github.com/Portkey-AI/gateway/issues/471)、[streaming 文档](https://portkey-ai-gateway.mintlify.app/features/streaming) |
| Bifrost / Helicone | 重试+fallback+自适应 LB | **未见公开的中段沉默触发重试/对冲实现**;Bifrost 生态文章把 "parallel hedged requests" 列为路由模式之一但无流式细节 | [Bifrost retries](https://bifrost.mintlify.app/features/retries-and-fallbacks)、[Helicone retries](https://docs.helicone.ai/features/advanced-usage/retries) |
**结论**: 业界对"流中段沉默"的落地形态收敛为 **间隔式不活跃看门狗 → 抛错(Timeout 子类)→ 交给既有重试/换源链**;"中段沉默触发并发副本(hedging)"在任何主流网关/库中**均未找到公开实现**——我们的 FIRST_COMPLETED 异源对冲若覆盖中段沉默,属于超业界现状的能力,但语义上可组合"看门狗 + 已取消输家不可复活"来实现。
### A4. serving 层: inter-token stall 的观测与成因
- **成因**: 连续批处理引擎把新请求的 prefill(算力密集)与在途请求的 decode(带宽密集)交错;vLLM 官方文档承认未分离时"decode 中插入 prefill 会导致 tail ITL 尖刺",故推 P/D 分离分别调 TTFT 与 ITL。([vLLM disagg prefill](https://docs.vllm.ai/en/v0.12.0/features/disagg_prefill/))
- **实测极端案例**: SGLang 严格 prefill-first 调度下,持续 prompt 负载使 decode 饿死到 **~1 batch/24s**——即中段沉默数十秒是调度器结构性产物,非异常。([SGLang #32549](https://github.com/sgl-project/sglang/issues/32549))
- **观测指标先例**: vLLM `vllm:inter_token_latency_seconds` 直方图(llm-d 运维文档建议用 `histogram_quantile` 盯分位,"ITL 高→输出断断续续");SGLang 新增 `forward_pass_duration_seconds`/`decode_step_latency_seconds`(按 prefill 桶×decode 请求数打标,专测 decode 被干扰)。([vLLM metrics](https://docs.vllm.ai/en/stable/design/metrics/)、[llm-d 观测文档](https://github.com/llm-d/llm-d/blob/3d74f4a6/docs/operations/observability/metrics.md)、[SGLang PR #29879](https://github.com/sgl-project/sglang/pull/29879))
- **缓解手段**: chunked prefill(vLLM 默认预算 2048 tokens/迭代,防长 prompt 冻结在途会话)、inter-prefill-budget(中位 TTFT 降 37%,防 HoL 阻塞)。([LeCompute 调度调优](https://lecompute.fr/en/runtimes/latency-against-latency/)、[vLLM PR #33743](https://github.com/vllm-project/vllm/pull/33743))
## 二、主题 B 详细发现
### B1. LLM 推理基准的标准时间口径
- **NVIDIA NIM/AIPerf 官方定义**: TTFT = 提交查询→收到首个 token(忽略空内容的首个响应;含排队+prefill+网络);E2E 请求延迟 = TTFT + Generation_time(首 token→末 token,剔除末尾 `[DONE]`/空响应);ITL =(E2E−TTFT)/(输出 token 数−1),**显式排除 TTFT**,分母减 1 使其只刻画 decode 段。([NVIDIA NIM metrics](https://docs.nvidia.com/nim/benchmarking/llm/latest/metrics.html))
- NVIDIA 技术博客同口径: "TTFT 含请求排队、prefill 与网络延迟";"E2E = TTFT + 生成时间;ITL 为相邻输出 token 间隔均值(不含首 token)"。([NVIDIA 博客](https://developer.nvidia.com/blog/llm-benchmarking-fundamental-concepts/))
- **排队与网络如何处理**: 基准口径**不扣除**——排队计入 TTFT/E2EL,网络计入 TTFT;跨工具可比的前提是定义对齐("Tool implementations vary, so compare results only when definitions align")。([NVIDIA NIM metrics](https://docs.nvidia.com/nim/benchmarking/llm/latest/metrics.html))
- **Anyscale/vLLM 口径**: 单请求 TPOT = ITL 均值 =(E2E−TTFT)/(token 数−1);聚合时 avg TPOT 按请求等权、avg ITL 按 token 加权——两者回答不同问题(单请求体感 vs 系统稳态流率)。([Anyscale metrics](https://docs.anyscale.com/llm/serving/benchmarking/metrics.md))
- **MLPerf Inference**: LLM server 场景收 TTFT + TPOT 两指标,TPOT 定义为"所有生成 token 间的平均间隔";延迟自 LoadGen 调度时刻起算(含排队)。([inference_rules.adoc](https://github.com/mlcommons/inference_policies/blob/master/inference_rules.adoc)、[MLPerf 5.1 说明](https://mlcommons.org/2025/09/small-llm-inference-5-1/))
- **陷阱先例**: vLLM bench serve 的 ITL 在 chunk 与 token 非 1:1 时(reasoning/tool-call parser 缓冲)算错——**chunk 边界 ≠ token 边界**,任何按 chunk 计时的口径都要声明这一点。([vLLM #27485](https://github.com/vllm-project/vllm/issues/27485));另有"ITL 低于 TPOT"的历史 bug(均值口径混淆)。([vLLM #6531](https://github.com/vllm-project/vllm/issues/6531))
### B2. wall-time vs on-CPU: 扣除外部阻塞时间的标准做法与陷阱
- **off-CPU analysis**(Brendan Gregg): CPU profiling 只看在 CPU 上执行的线程;off-CPU 分析测量线程被阻塞(off-CPU)的时间并带上下文(stack trace),两者互补以解释 100% 线程时间。off-CPU 时间 = 调度器 off-cpu→on-cpu 探针的时间戳差。([Off-CPU Analysis](https://www.brendangregg.com/offcpuanalysis.html)、[offcputime 工具](https://github.com/brendangregg/bpf-perf-tools-book/blob/master/originals/Ch06_CPUs/offcputime.bt))
- **方法论要点**: ①墙钟分解必须成对报告(on-CPU + off-CPU flame graph 同看),只报"净时间"会丢失归因;②阻塞时间若发生在请求的同步路径上,则直接等比影响延迟——即"扣除"是分析手段,不改事实;③归因需要上下文(stack/wakeup stack),单给一个总数无法行动。([Off-CPU Flame Graphs](https://www.brendangregg.com/FlameGraphs/offcpuflamegraphs.html)、[Wakeup/Off-Wake](https://www.brendangregg.com/blog/2016-02-01/linux-wakeup-offwake-profiling.html))
- 映射到我们的场景: `generation_ms`(墙钟)= 活跃生成(on-CPU 类比)+ 中段沉默(off-CPU 类比);Gregg 范式要求的"带归因的分解"对应"沉默总量 + 沉默次数 + 各段位置",而非只给一个净数。
### B3. 从 token 到达序列扣沉默段的计量学先例与陷阱
- **唯一找到的成体系先例 — pi-tps**: 每个流式更新测量与上一更新的间隔;**gap ≥ 500ms 判为 stall**,整段累计为 `stallMs`、连续停顿算一次 `stallCount`;算 TPS 时把 stall 从 streaming window 中扣除;每个 message 边界重置 stall 时钟(工具调用间隙不误计);三分支防御: 主口径(≥5 次更新、平均间隔 ≥1ms、stall < 活跃时间)、保守兜底(用含 TTFT 的全窗口,宁可低估)、**结构性不可辨识时返回 null 显示 `—`**(burst 交付/样本不足)。([pi-tps 包页](https://pi.dev/packages/@logicouo/pi-tps?page=49))
- **vLLM 服务端口径**: `generation_time_ms` = 首 token→末 token 的 decode 时间,**已排除排队与 prefill/TTFT,但仍包含引擎侧中段停顿**(preempt/调度等待);`mean_itl_ms` 单 token 响应为 null;`n>1` 时整个 metrics 抑制(无法归因)——口径排除条款的范本。([vLLM per-request metrics](https://docs.vllm.ai/en/stable/features/per_request_metrics/))
- **OpenRouter 用户侧口径**: latency = TTFT(网络+排队+prefill),throughput = 解码 TPS;`Total = TTFT + tokens/TPS`——二元口径,不做沉默扣除,但把"等"与"生成"分开呈现是行业用户心智。([OpenRouter 延迟指南](https://openrouter.ai/docs/guides/best-practices/latency-and-performance)、[供应商评估](https://openrouter.ai/blog/insights/evaluate-llm-provider-performance/))
- **三种扣除形态的判定**(综合以上先例):
| 形态 | 评价 |
| --- | --- |
| 只减最大一段 | 无任何先例;多段沉默场景系统性高估活跃时间,不推荐 |
| 减所有超阈值段(pi-tps 式) | 有唯一直接先例;阈值外小停顿计入活跃时间,偏差有界且方向保守,**推荐** |
| 减所有段(纯 token 间隔求和) | 等价于重定义 ITL;chunk≠token 时不可算(vLLM #27485),放弃 |
- **尾部归属**: AIPerf 显式剔除末尾 `[DONE]`/空响应(E2E 止于末 token);pi-tps 的 stream span 止于最后一次 message_update;**最后一个 token 的间隔属于生成段内,流终止信号之后的时间一律不计**。vLLM 单 token 响应 `mean_itl_ms = null` 提示: token 数 <2 时一切间隔类口径应输出 null 而非 0。
## 三、对库设计的具体建议
### 3.1 中段对冲的赢家裁定语义
1. **裁定事件分两级**: 现行"首 token 竞速"保留(命名对齐业界: TTFT race);新增**中段沉默竞速**——主路沉默超过 `stall_after_s` 触发异源副路,副路同样按"首个内容 chunk"裁定。
2. **commit 点**: 借鉴 Envoy/gRPC"首个可接受响应即 commit":一旦某一路的**首个内容 chunk 已向下游交付**,该路成为 committed winner,其余路取消、不得复活——chat completion 的增量内容**不可拼接**(两路文本分叉,缝合即结果污染),这是 Env名"判赢不回头"语义在 LLM 场景的必然推论。
3. **commit 前摇摆**: 若副路先出 chunk 而主路恰好恢复,以**先交付者**为准(FIRST_DELIVERED),不做内容比车。
4. **committed winner 再沉默**: 不再对冲(无副本可换语义),走看门狗 → `stall_timeout` 抛错 → 错误分类 Transient → 交既有重试/换源链(Codex CLI 先例);已在下游的增量内容如实保留并标记截断。
5. **成本与放大控制**: 借鉴 gRPC `retryThrottling`(token bucket 压对冲率)与 Tail at Scale 级联警告: 对冲预算独立计量,故障期自动收敛;输家 est 不喂熔断的现行设计保持。
### 3.2 沉默扣除口径的推荐形态
1. **记录原语**: 传输层记录每个内容 chunk 的单调时间戳序列(空 chunk/SSE keepalive 不计,LangChain 语义),遥测侧由时间戳派生,不在热路径做判定。
2. **派生字段**(CallStats 只增不删):
| 字段 | 口径 |
| --- | --- |
| `generation_ms`(已有) | 首内容 chunk→末内容 chunk 墙钟(vLLM `generation_time_ms` 同口径) |
| `stall_ms` | Σ 所有 > `stall_threshold_ms` 的相邻 chunk 间隔(pi-tps 式,非只减最大段) |
| `stall_count` | 超阈值间隔个数(连续停顿合并为一次) |
| `active_generation_ms` | `generation_ms − stall_ms`,**派生值、永不覆盖墙钟原值**(Gregg 分解原则) |
3. **阈值形态**: 固定默认值(pi-tps 用 500ms,Anthropic ping 间隔 ~30s 提示阈值需 ≫ keepalive 周期,建议默认 2–5s 量级、可配),不做自适应分位阈值(避免与对冲阈值耦合出反馈环)。
4. **排除条款照抄 vLLM**: chunk 数 <2 时间隔类字段为 null;非流式调用三个新字段全 null;对冲输家的沉默数据保留在输路记录但不进赢家 CallStats。
5. **对冲与沉默的关系**: `stall_after_s`(对冲触发)与 `stall_threshold_ms`(计量口径)是**两个独立参数**——前者触发动作、后者只做观测,命名与语义都必须分开。
### 3.3 配置命名先例对照
| 语义 | 业界先例 | 建议沿用风格 |
| --- | --- | --- |
| 首 token 超时 | LiteLLM `ttft_timeout`、`stream_timeout` | `HEDGE__AFTER_S` 已符合;看门狗类用 `STREAM__TTFT_TIMEOUT_S` |
| 中段不活跃看门狗 | LangChain `stream_chunk_timeout`(默认 120s)、LiteLLM 提案 `...STREAM_INACTIVITY_TIMEOUT_SECONDS` | `STREAM__STALL_TIMEOUT_S` |
| 中段对冲触发阈值 | 无直接先例(gRPC `hedgingDelay` 是按响应头的节拍) | `HEDGE__STALL_AFTER_S` |
| 沉默计量阈值 | pi-tps 固定 500ms(代码常量) | `STREAM__STALL_THRESHOLD_MS` |
| 对冲并发上限 | gRPC `maxAttempts`(钳 5) | 现有单副路设计已等价 maxAttempts=2 |
## 四、来源清单
**保留(关键证据)**
- [The Tail at Scale (CACM 2013)](https://cacm.acm.org/research/the-tail-at-scale/) / [作者 PDF](https://barroso.org/publications/TheTailAtScale.pdf) — 对冲原始定义
- [semicolony 批注版 Tail at Scale](https://semicolony.dev/papers/tail-at-scale) — 论文未覆盖流式响应、级联放大批评
- [Envoy HTTP routing 文档](https://www.envoyproxy.io/docs/envoy/latest/intro/arch_overview/http/http_routing.html) / [超时 FAQ](https://www.envoyproxy.io/docs/envoy/latest/faq/configuration/timeouts.html) / [PR #10082](https://github.com/envoyproxy/envoy/pull/10082) — 判赢在响应头、流式停顿不重新对冲
- [gRPC A6 提案](https://github.com/grpc/proposal/blob/master/A6-client-retries.md) / [官方 hedging 指南](https://grpc.io/docs/guides/request-hedging/) — hedgingDelay/非致命码/pushback/throttle 语义
- [LiteLLM PR #29767](https://github.com/BerriAI/litellm/pull/29767) / [config_settings](https://docs.litellm.ai/docs/proxy/config_settings) / [Issue #29602](https://github.com/BerriAI/litellm/issues/29602) / [PR #36024](https://github.com/BerriAI/litellm/pull/36024) — 中段看门狗形态、keepalive 重置 read timeout 失效、遥测误记教训
- [LangChain PR #36949](https://github.com/langchain-ai/langchain/pull/36949) / [stream_chunk_timeout 参考](https://reference.langchain.com/python/langchain-openai/chat_models/base/BaseChatOpenAI/stream_chunk_timeout) — 已合并的内容 chunk 间隔看门狗
- [OpenAI Codex issue #33051](https://github.com/openai/codex/issues/33051) — 沉默检测→整路重试生产先例
- [Portkey issue #471](https://github.com/Portkey-AI/gateway/issues/471) — 总时长超时误杀流式的反面教材
- [bhope/hedge](https://github.com/bhope/hedge) / [InfoQ 自适应对冲](https://www.infoq.com/articles/adaptive-hedged-requests-p99-latency/) — DDSketch 动态阈值 + token bucket 压放大率
- [llms.blog 生产 LLM 对冲](https://www.llms.blog/posts/request-hedging-in-production-llm-serving-architecture-tail-latency-mitigation-and-cancellation-protocols) — TTFT 竞速架构(其"卡顿只在 prefill"断言须警惕)
- [vLLM disagg prefill](https://docs.vllm.ai/en/v0.12.0/features/disagg_prefill/) / [SGLang #32549](https://github.com/sgl-project/sglang/issues/32549) / [SGLang PR #29879](https://github.com/sgl-project/sglang/pull/29879) / [llm-d 观测文档](https://github.com/llm-d/llm-d/blob/3d74f4a6/docs/operations/observability/metrics.md) — 中段卡顿的 serving 层成因与指标
- [NVIDIA NIM metrics](https://docs.nvidia.com/nim/benchmarking/llm/latest/metrics.html) / [NVIDIA 博客](https://developer.nvidia.com/blog/llm-benchmarking-fundamental-concepts/) — TTFT/ITL/E2EL 官方口径
- [Anyscale metrics](https://docs.anyscale.com/llm/serving/benchmarking/metrics.md) — TPOT/ITL 聚合权重
- [MLPerf inference_rules](https://github.com/mlcommons/inference_policies/blob/master/inference_rules.adoc) — 基准延迟自调度时刻起算
- [vLLM per-request metrics](https://docs.vllm.ai/en/stable/features/per_request_metrics/) / [metrics 设计](https://docs.vllm.ai/en/stable/design/metrics/) — generation_time_ms 命名与排除条款范本
- [vLLM #27485](https://github.com/vllm-project/vllm/issues/27485) — chunk≠token 口径陷阱
- [Brendan Gregg Off-CPU Analysis](https://www.brendangregg.com/offcpuanalysis.html) / [Off-CPU Flame Graphs](https://www.brendangregg.com/FlameGraphs/offcpuflamegraphs.html) — 墙钟分解与归因原则
- [pi-tps](https://pi.dev/packages/@logicouo/pi-tps?page=49) — 沉默扣除唯一直接先例(500ms 阈值、stallMs/stallCount、三分支防御)
- [OpenRouter 延迟指南](https://openrouter.ai/docs/guides/best-practices/latency-and-performance) — 用户侧 TTFT/TPS 二元口径
**弃用(及原因)**
- learnaivisually、hivenet、clickhouse、themainthread 等科普稿 — 定义与 NVIDIA/Anyscale 一手文档重复且更浅
- macnica(代理商博客)、AWS Neuron/营销页、acecloud、saraswatmks 博客 — 营销/转载性质
- Portkey/Helicone/Bifrost 营销文档 — 只证实"无中段沉默对冲实现",不承载技术语义
- Solana/Rust hedged RPC crates — 与 LLM 流式无关的一元 RPC 竞速
- eunomia eBPF 教程 — Gregg 一手文已覆盖
## 五、未能确认的事项
1. **"中段沉默触发并发副本"是否有任何已投产实现**——未找到;不排除闭源网关(如各云厂 AI 网关)内部存在。建议以"看门狗→重试"为基线交付,把中段对冲作为可选增强。
2. pi-tps 的 500ms 阈值是否经生产校准(单个开源扩展,样本未知);我们的阈值应基于自有遥测分布定,不照抄数值。
3. Tail at Scale 之后是否有**学术**流式对冲后续工作(本次检索未见;如需可再查 IEEE/ACM 数据库)。
@@ -0,0 +1,107 @@
---
type: finding
node_id: finding:2026-09-11-streaming-stall-validation
title: "1.3.8 流式中段沉默:净口径与中段对冲验证报告"
date: 2026-09-14
---
# 1.3.8 流式中段沉默:净口径与中段对冲验证报告
> 计划:`research-wiki/plans/2026-09-11-streaming-stall.md`;设计:`research-wiki/designs/2026-09-11-streaming-stall-design.md`(人类已批准 + 独立审查补遗 B1-B4/N1-N9 全处置)。
> 分支 `feature/1.3.8-streaming-stall`;基线 main `a28d944`(1.3.7)+ 设计/计划提交 `97de410`/`6f3a620`;代码提交 `901a193`(T1)/`53bcf43`(T2)/`e0b0be0`(T3)/`2ac05a2`(T4),文档提交见本文件 git 历史。
> 所有命令在 `PolyGateway` conda 环境执行,未接管道(退出码不失真)。
## 1. 红绿证据索引
证据目录 `tests/outputs/138/{t1,t2,t3,t4}`(不提交,本地留存)。与 137 惯例不同,138 日志**未逐文件落 `EXIT_CODE=` 行**;退出码由日志内 `conda ... failed` ERROR 行(红)与 pytest 汇总行(绿)佐证,如实登记。
| 任务 | 相位 | 证据文件 | 结果与失败形态 |
| --- | --- | --- | --- |
| T1 端口事件 + stall 原语 | 红(批次 A) | `t1/red-batch-a.log` | exit≠0:收集期 `ImportError: cannot import name '_stall_from_chunk_times'`(纯函数不存在,非断言值不符) |
| T1 | 绿(批次 A) | `t1/green-batch-a.log` | 260 passed(test_openai_compat + test_types) |
| T1 | 绿(unit 全套) | `t1/green-unit.log` | 1602 passed,既有断言一行未改 |
| T2 净口径记账 | 红(批次 B) | `t2/red-batch-b.log` | exit≠0:6 failed——`CallStats`/`_CallContext` 字段缺失、`generation_ms` 仍得墙钟值、两败轮归因未置 None |
| T2 | 绿(批次 B) | `t2/green-batch-b.log` | 40 passed |
| T2 | 绿(unit / unit+contracts) | `t2/green-unit.log` / `t2/green-unit-contracts.log` | 1608 passed / 1669 passed, 17 skipped |
| T3 遥测两列 | 红(批次 C) | `t3/red-batch-c.log` | exit≠0:14 failed, 224 passed——24 处 AssertionError(冻结清单先扩即红:列数 36≠38、列序末两位、DDL 首现顺序、占位符数)+ 装配闸/落行断言未实现形态 |
| T3 | 绿(批次 C) | `t3/green-batch-c.log` | 238 passed |
| T3 | 绿(unit+contracts) | `t3/green-unit.log` | 1673 passed, 17 skipped |
| T4 中段对冲 + 配置守卫 | 红(批次 D) | `t4/red-batch-d.log` | exit≠0:16 failed, 153 passed——9×`TypeError: RetryMW.__init__() got an unexpected keyword argument 'stall_after_s'`、2×`AttributeError: 'GatewaySettings' object has no attribute ...`、键未识时 `STREAM` 段被当源(`['qwen_1','stream_1'] == ['qwen_1']`)、守卫未抛/默认值未生效断言,均为未实现形态 |
| T4 | 绿(批次 D,首轮) | `t4/green-batch-d-first.log` | 169 passed(与红相位 16+153 总量一致) |
| T4 | 绿(unit / contracts) | `t4/green-unit.log` / `t4/green-contracts.log` | 1688 passed, 17 skipped / 61 passed, 17 skipped(批次 E 默认关闭回归门:既有语义断言一行未改,例外仅为计划登记的签名/清单同步) |
| T4 | lint | `t4/lint.log` | `make lint` 通过,ruff 全绿,import-linter Contracts: 1 kept, 0 broken |
| T1 期 ruff 先红 | 红 | `t1/ruff.log` | 1 error(`strict=` 缺显式值),T2 起 `ruff.log` 转 All checks passed |
## 2. 批次与提交映射
| 提交 | 计划任务 | 覆盖批次(计划 §5) | 关键断言 |
| --- | --- | --- | --- |
| `901a193` | T1 观测原语与端口契约 | A | 流式每增量 chunk 置位 content_event;reasoning chunk 计活性;非流式永不置位;None 不观测行为不变;漏传必填 kw 即 TypeError;stall 纯函数(超阈累计/恰等不减/<2 返回 None/尾部不计/空帧不计时);`SourceConfig.stall_threshold_ms` 默认 1000 且 0/负/bool 拒 |
| `53bcf43` | T2 净口径记账 | B | 两字段默认 None 旧构造不炸;`generation_ms == wall − stall` 且还原公式成立;clamp 负值归零;stall None 不退减;两败轮归因 None |
| `e0b0be0` | T3 遥测两列 | C | 冻结清单 38 列与列序/DDL/占位符同源;SQLite 落行两列恰为传入值;auto_migrate 幂等补列(二次初始化);manual 档点名两条新 ALTER;取消输家行 stall 恒 None;装配闸三负例照旧绿 |
| `2ac05a2` | T4 中段对冲 + 守卫 | D、E | 首 token 后沉默触发对冲且赢家净口径;同 pass 复检防误触发;四格独立矩阵;仅 stall+非流式退单路;看门狗掐死主路对冲路存活且记账照常;两败计一次预算;stall 等待中外部取消穿透无残留;对冲路 content_event 恒 None;阈值三层可调;STREAM 保留段;四守卫(有效最小值/deadline 冲突 ValueError、单源 warning、Embedding/OCR B4 warning) |
## 3. 实测核对(文档承诺 vs 运行实测,T5 交付时本会话复核)
| 承诺 | 核对方式 | 实测结果 |
| --- | --- | --- |
| `CallStats` 两新字段默认值 | `CallStats(logical_call_id='x', attempts=1, total_latency_ms=5)` 仅旧参构造 | `generation_ms=0`、`hedges=0`、`hedge_won=False`、`stall_ms=None`、`stall_count=None`,构造不炸 |
| 协议字段数 | `inspect.signature(TelemetryRecorder.record_llm_call)` | 38 参(36 → 38,含 self 不计) |
| 遥测列 | `telemetry/schema.py::COLUMNS` | `len == 38`,末两位 `('stall_ms', 'stall_count')` |
| 两键缺省与三层 | `GatewaySettings.from_env(env=...)` 注入单源最小 env | 不设两键 → `stall_threshold_ms=1000`、`hedge_stall_after_s=None`,且已分发进 SourceConfig(1000);设 `LLM__STREAM__STALL_THRESHOLD_MS=250`/`LLM__HEDGE__STALL_AFTER_S=8` → 250/8.0 |
| 值域校验落点 | `dataclasses.replace(src, stall_threshold_ms=0)` | `ValueError: SourceConfig.stall_threshold_ms 必须是正整数`(replace 触发 `__post_init__` 重跑,校验只此一处) |
| 版本号不动 | `pyproject.toml` 与 `src/polygateway/__init__.py` | 两处均 1.3.7,本计划不 bump、不 tag、不发布 |
## 4. 验收命令与退出码(T1–T4 各卡)
| 任务 | 验收命令(均 `conda run -n PolyGateway`,禁接管道) | 结果 |
| --- | --- | --- |
| T1 | `pytest tests/unit/test_openai_compat.py tests/unit/test_types.py -q` → `pytest tests/unit -q` | 260 passed → 1602 passed,exit 0 |
| T2 | `pytest tests/unit/test_types.py tests/unit/test_retry.py -q` → `pytest tests/unit tests/contracts -q` | 批次 40 passed → 1669 passed, 17 skipped,exit 0 |
| T3 | `pytest tests/unit/test_telemetry.py tests/unit/test_ports.py tests/unit/test_hedge.py -q` → `pytest tests/unit tests/contracts -q` | 238 passed → 1673 passed, 17 skipped,exit 0 |
| T4 | `pytest tests/unit/test_stall_hedge.py tests/unit/test_config.py -q` → `pytest tests/unit tests/contracts -q` → `make lint` | 169 passed → 1688+61 passed,exit 0;lint 通过 |
合并前验收门(计划 §6:全套件 + slow 交集子集 + 整分支审查 + verifier)**不在 T5 范围**,随合并/发布流程执行;结果届时补记本节或另文。
## 5. 豁免索引(未跑项与去向)
| 未跑项 | 理由 | 去向 |
| --- | --- | --- |
| PG 实库遥测迁移用例(`tests/integration/test_postgres_telemetry.py`) | 执行期无沙箱可跑(计划 §7 阻塞矩阵已登记此情形);批次 C 的 SQLite 幂等用例(二次初始化)保留未删,PG 侧 `_EXPECTED_COLUMNS` 冻结清单已同步扩展 | **发布门 slow 交集补跑**(CLAUDE.md §4.4.1 第 4 步;本 diff 触及 telemetry schema,PG 实库形态在交集内) |
| `tests/e2e/` 与 Redis 时间语义变体(slow) | T1–T4 未动 `streaming.py`/限流 Lua/`Permit` 端口/`backends/**`;但 diff 触及 retry(结算/取消/编排)与公开入口 `complete` 签名 | 发布门按 diff 交集选子集:**Redis 时间语义变体 + 真实网关冒烟届时在交集内**(计划 §6 门 3 已登记) |
| `test_thinking_live.py` 全模型能力矩阵 | 本 diff 零触碰 `thinking.py`/能力注册表/相关 e2e 设施 | 按 2026-09-10 生效的交集规则**豁免**,复用 1.3.7 已登记的有效矩阵证据,不外推到已变化路径 |
## 6. T4 worker 超时恢复实录(提交链完整性说明)
T4 由前台委派 worker 执行,worker 在收尾阶段(证据落盘后、提交前)会话超时失联。处置:父会话复核 `tests/outputs/138/t4/` 五份证据(red-batch-d 16 红形态均为未实现错误、green-batch-d-first 169 passed 与红相位总量一致、green-unit 1688 / green-contracts 61 全绿、lint 1 kept 0 broken)并核对工作区 diff 精确命中计划 §2.1 表定五文件(`client.py`/`config.py`/`retry.py`/`test_config.py`/`test_stall_hedge.py`,无越界改动)后,代为提交 `2ac05a2`。**教训沉淀**:委派任务的提交动作与证据落盘之间隔着一个会话生命周期,证据先行落盘(计划 §1 证据纪律)使本次恢复无需重跑任何测试。
## 7. 残余复述(设计/计划已批准口径)
| 项 | 口径 |
| --- | --- |
| 中段对冲无业界公开先例 | 调研 §A3 结论,CHANGELOG/README/ARCHITECTURE 三处诚实标注;2A 看门狗链仍是不启用对冲时的默认行为 |
| `generation_ms` 语义突变 | 人类「窗口期最小代价」裁决放行的例外(字段发布仅 2 天/消费者唯一/遥测无账本);披露义务已在 T5 三处文档落地 |
| 复检竞速窗口 | N3① 复检 `is_set()` 只能把误触发窗口收窄到同一事件循环 pass,不能归零——已是设计批准的最强形态 |
| 挂起率统计的不对称 | 看门狗掐死喂熔断 vs 对冲输家取消不喂(1.3.6/1.3.7 各自既有语义,本版不改任一);运维侧统计须知 |
## 8. 结论
T1–T4 全部行为变更具备先红后绿证据(§1),默认关闭回归门成立(批次 E:unit 1688 + contracts 61 全绿,既有语义断言一行未改),lint 与 import-linter 契约通过;文档承诺经运行实测核对(§3)。版本 bump、tag、发布与 slow 交集子集**不在本计划内**,按 CLAUDE.md §4.4.1 另行执行(发布时按 §5 豁免索引补跑交集项)。
## 5. 合并前独立验证补跑(2026-09-14,verifier P2-1/P2-2 缺口闭合)
| 项 | 证据 | 结果 |
| --- | --- | --- |
| 全套件(含 integration) | `tests/outputs/138/verify/full-suite2.log` | **1764 passed, 23 skipped, exit=0** |
| PG 遥测 30 用例 | `pg-integration-rerun2.log` | 30 passed exit=0(首轮 12 failed → 根因: `_minimal_fields` 测试 helper 未同步 1.3.8 两列,`postgres.py:450` 按物理列取值 KeyError;修复 helper + `test_pre_135` 期望字典,同类 B1 测试同步缺口,非生产缺陷——生产路径由装配闸强制全签名) |
| 关键用例重跑 | `key-reruns.log` | 10 passed exit=0(净口径精确值/复检防误触发/看门狗掐死汇合/遥测迁移幂等) |
| make lint | `verify/lint.log` | exit=0,Contracts: 1 kept, 0 broken |
## 6. 发布门(2026-09-14,main b0232b7)
| 门 | 证据 | 结果 |
| --- | --- | --- |
| 合并后 make lint | `tests/outputs/138/release-lint.log` | exit=0,Contracts: 1 kept |
| 合并后全套件 | `tests/outputs/138/release-full-gate.log` | 1764 passed, 23 skipped, exit=0 |
| slow 交集(Redis 时间语义变体+网关冒烟) | `tests/outputs/138/release-slow-scoped.log` | 22 passed,EXIT_CODE=0,20m17s |
| thinking 矩阵豁免 | 交集规则(CLAUDE.md §4.4.1 第4步) | 本 diff 未动 thinking/能力注册表/e2e 设施,复用 1.3.7 矩阵证据 |
@@ -0,0 +1,18 @@
# #29 分发来源前置核查
日期:2026-10-01;基线 0e29dea;在 T1 前执行。本记录不是法律认证。
| 范围 | 实际核查与结论 |
|---|---|
| 跟踪源码 | `git ls-files src` 列出 polygateway Python 模块与 py.typed,无 vendor 子树、外部 LICENSE/NOTICE、打包外部代码目录 |
| 内部移植 | rg 检索移植/参考/Copyright/SPDX/copied/adapted:streaming、sources、Redis Lua、ports、openai_compat 的来源为三个内部项目;用户已明确均本人原创并授权 Apache-2.0 |
| 外部参考声明 | 阅读 types.py Effort、providers.py ThinkingWire/注册表、thinking.py 能力证据段;使用的是协议词汇/参数形态/实测结论,未发现来自外部仓的成段实现或第三方版权头;不把相同协议字符串认定为复制实现 |
| 打包边界 | pyproject 仅从 src 发现包,reference 不跟踪、不纳入分发;本次不复制外部参考实现 |
| 许可来源 | 系统 `/usr/share/common-licenses/Apache-2.0` 提供标准许可正文;构建时核对包内文本与元数据 |
未发现需额外通知的实际复制片段,因此不编造 NOTICE。外部参考仓含 AGPL 不等于本库受其许可约束;用户对原创代码的授权也不能覆盖第三方。实施/审查若发现新的实际复制证据,停止并提交主管处理,不用本文替代核查。
## 审查意见处置
P1 采纳:协议快照缺 role 不可直接进入下一轮,SSE/非流式/缓存统一要求 assistant;不补造 role,默认 text 不变。
P2 采纳:将本次来源核查前移到 T0,T6 仅复核及落实 LICENSE/打包/文档。两项均是已批准方向的正确性修订,无需新增用户授权。
+67
View File
@@ -225,6 +225,31 @@
"id": "finding:2026-09-09-134-thinking-contracts-validation", "id": "finding:2026-09-09-134-thinking-contracts-validation",
"label": "1.3.4 T0–T4 与 T7 确定性验证", "label": "1.3.4 T0–T4 与 T7 确定性验证",
"type": "finding" "type": "finding"
},
{
"id": "design:2026-10-01-issue29-openai-fidelity-design",
"label": "OpenAI消息保真设计",
"type": "design"
},
{
"id": "plan:2026-10-01-issue29-openai-fidelity",
"label": "OpenAI消息保真实现计划",
"type": "plan"
},
{
"id": "review:2026-10-01-issue29-cache-review",
"label": "保真缓存审查修复与1.4.0准备",
"type": "review"
},
{
"id": "plan:2026-09-10-24-hedged-requests",
"label": "issue #24 长尾对冲请求与裸生成时间实施计划",
"type": "plan"
},
{
"id": "design:2026-09-10-24-hedged-requests-design",
"label": "长尾对冲请求(issue #24)设计",
"type": "design"
} }
], ],
"links": [ "links": [
@@ -472,6 +497,48 @@
"relation": "implements", "relation": "implements",
"evidence": "1.3.5 逻辑调用统计与结构化失败诊断实施计划落地已批准设计", "evidence": "1.3.5 逻辑调用统计与结构化失败诊断实施计划落地已批准设计",
"added": "2026-09-09T13:42:00.298915+00:00" "added": "2026-09-09T13:42:00.298915+00:00"
},
{
"source": "plan:2026-10-01-issue29-openai-fidelity",
"target": "design:2026-10-01-issue29-openai-fidelity-design",
"relation": "implements",
"evidence": "已批准方向与独立审查P1/P2修订",
"added": "2026-10-01T17:52:42.425248+00:00"
},
{
"source": "schema:llm-calls",
"target": "design:2026-10-01-issue29-openai-fidelity-design",
"relation": "implements",
"evidence": "复用既有单出口与schema,无新增列;工具error为attempt失败",
"added": "2026-10-01T17:52:43.831331+00:00"
},
{
"source": "review:2026-10-01-issue29-cache-review",
"target": "design:2026-10-01-issue29-openai-fidelity-design",
"relation": "informs",
"evidence": "统一缓存与transport的有效产物判据,保持内核依赖边界",
"added": "2026-10-01T18:27:16.434742+00:00"
},
{
"source": "plan:2026-09-10-24-hedged-requests",
"target": "design:2026-09-10-24-hedged-requests-design",
"relation": "implements",
"evidence": "计划头部与设计 §9 批准状态(2026-09-10 H1-H8 全数获批)",
"added": "2026-09-10T16:48:37.849653+00:00"
},
{
"source": "plan:2026-10-21-issue34-ocr-document",
"target": "design:2026-10-21-issue34-ocr-document-design",
"relation": "implements",
"evidence": "实现 #34 已批准设计 H1/H5/H6",
"added": "2026-10-22T02:45:49.844612+00:00"
},
{
"source": "review:2026-10-22-issue34-branch-review",
"target": "plan:2026-10-21-issue34-ocr-document",
"relation": "informs",
"evidence": "合并前验收与审查结论",
"added": "2026-10-22T06:15:33.014451+00:00"
} }
] ]
} }
+28 -11
View File
@@ -1,9 +1,8 @@
# Research Wiki 索引 # Research Wiki 索引
> 自动生成,更新时间:2026-09-09 18:01 UTC > 自动生成,更新时间:2026-10-22 06:15 UTC
## design (43)
## design (49)
- [1.3.4 推理意图与测试证据设计](designs/2026-09-09-134-thinking-contracts-design.md) `design:2026-09-09-134-thinking-contracts-design` - [1.3.4 推理意图与测试证据设计](designs/2026-09-09-134-thinking-contracts-design.md) `design:2026-09-09-134-thinking-contracts-design`
- [2026-07-20-m1-core-design](designs/2026-07-20-m1-core-design.md) `design:2026-07-20-m1-core-design` - [2026-07-20-m1-core-design](designs/2026-07-20-m1-core-design.md) `design:2026-07-20-m1-core-design`
- [2026-07-20-m2-distributed-design](designs/2026-07-20-m2-distributed-design.md) `design:2026-07-20-m2-distributed-design` - [2026-07-20-m2-distributed-design](designs/2026-07-20-m2-distributed-design.md) `design:2026-07-20-m2-distributed-design`
@@ -25,6 +24,9 @@
- [2026-08-24-issue15-telemetry-pool-lifecycle-design](designs/2026-08-24-issue15-telemetry-pool-lifecycle-design.md) `design:2026-08-24-issue15-telemetry-pool-lifecycle-design` - [2026-08-24-issue15-telemetry-pool-lifecycle-design](designs/2026-08-24-issue15-telemetry-pool-lifecycle-design.md) `design:2026-08-24-issue15-telemetry-pool-lifecycle-design`
- [2026-09-04-reasoning-effort-design](designs/2026-09-04-reasoning-effort-design.md) `design:2026-09-04-reasoning-effort-design` - [2026-09-04-reasoning-effort-design](designs/2026-09-04-reasoning-effort-design.md) `design:2026-09-04-reasoning-effort-design`
- [2026-09-09-135-call-observability-design](designs/2026-09-09-135-call-observability-design.md) `design:2026-09-09-135-call-observability-design` - [2026-09-09-135-call-observability-design](designs/2026-09-09-135-call-observability-design.md) `design:2026-09-09-135-call-observability-design`
- [2026-09-09-136-call-budgets-design](designs/2026-09-09-136-call-budgets-design.md) `design:2026-09-09-136-call-budgets-design`
- [2026-09-10-24-hedged-requests-design](designs/2026-09-10-24-hedged-requests-design.md) `design:2026-09-10-24-hedged-requests-design`
- [2026-09-11-streaming-stall-design](designs/2026-09-11-streaming-stall-design.md) `design:2026-09-11-streaming-stall-design`
- [est_tokens 解耦: 拆分限流预扣与遥测用量兜底(issue #2)](designs/est-tokens-decoupling.md) `design:est-tokens-decoupling` - [est_tokens 解耦: 拆分限流预扣与遥测用量兜底(issue #2)](designs/est-tokens-decoupling.md) `design:est-tokens-decoupling`
- [GatewaySettings 装配校验补齐(第二轮)](designs/settings-invariants-round-2.md) `design:settings-invariants-round-2` - [GatewaySettings 装配校验补齐(第二轮)](designs/settings-invariants-round-2.md) `design:settings-invariants-round-2`
- [GatewaySettings 跨字段不变量守卫的生效范围](designs/settings-invariant-guards.md) `design:settings-invariant-guards` - [GatewaySettings 跨字段不变量守卫的生效范围](designs/settings-invariant-guards.md) `design:settings-invariant-guards`
@@ -38,6 +40,8 @@
- [M2.5 治理韧性: 半死源隔离与健康感知调度](designs/m25-resilience.md) `design:m25-resilience` - [M2.5 治理韧性: 半死源隔离与健康感知调度](designs/m25-resilience.md) `design:m25-resilience`
- [M3 OCR 端口族设计](designs/m3-ocr.md) `design:m3-ocr` - [M3 OCR 端口族设计](designs/m3-ocr.md) `design:m3-ocr`
- [M4 迁移验证设计(GovDoc→CHS,发 v1.0)](designs/m4-migration.md) `design:m4-migration` - [M4 迁移验证设计(GovDoc→CHS,发 v1.0)](designs/m4-migration.md) `design:m4-migration`
- [OCR 整份文档解析设计(#34)](designs/2026-10-21-issue34-ocr-document-design.md) `design:2026-10-21-issue34-ocr-document-design`
- [OpenAI消息保真设计](designs/2026-10-01-issue29-openai-fidelity-design.md) `design:2026-10-01-issue29-openai-fidelity-design`
- [stall 判定改为非生产性等待口径](designs/issue8-stall-budget.md) `design:issue8-stall-budget` - [stall 判定改为非生产性等待口径](designs/issue8-stall-budget.md) `design:issue8-stall-budget`
- [响应可观测字段扩展(Issue #3)](designs/response-observability-fields.md) `design:response-observability-fields` - [响应可观测字段扩展(Issue #3)](designs/response-observability-fields.md) `design:response-observability-fields`
- [建表前先探测,判死只认「确定写不进去」](designs/issue9-telemetry-ddl-probe.md) `design:issue9-telemetry-ddl-probe` - [建表前先探测,判死只认「确定写不进去」](designs/issue9-telemetry-ddl-probe.md) `design:issue9-telemetry-ddl-probe`
@@ -45,21 +49,26 @@
- [推理开关能力建模与 reasoning_tokens 采集(issue #5 + #6)](designs/2026-08-02-thinking-capability-design.md) `design:2026-08-02-thinking-capability-design` - [推理开关能力建模与 reasoning_tokens 采集(issue #5 + #6)](designs/2026-08-02-thinking-capability-design.md) `design:2026-08-02-thinking-capability-design`
- [推理档位一等化设计(issue #20 及其一般形式)](designs/reasoning-effort.md) `design:reasoning-effort` - [推理档位一等化设计(issue #20 及其一般形式)](designs/reasoning-effort.md) `design:reasoning-effort`
- [治理后端故障归位为 scope 级不可用(Issue #7)](designs/governance-backend-error.md) `design:governance-backend-error` - [治理后端故障归位为 scope 级不可用(Issue #7)](designs/governance-backend-error.md) `design:governance-backend-error`
- [统一完成判定设计(#30/#31)](designs/2026-10-14-issue30-31-completion-verdict-design.md) `design:2026-10-14-issue30-31-completion-verdict-design`
- [调用方自定义维度设计(issue #11)](designs/issue11-caller-dimensions.md) `design:issue11-caller-dimensions` - [调用方自定义维度设计(issue #11)](designs/issue11-caller-dimensions.md) `design:issue11-caller-dimensions`
- [采样参数透传设计(issue #4)](designs/sampling-params.md) `design:sampling-params` - [采样参数透传设计(issue #4)](designs/sampling-params.md) `design:sampling-params`
## finding (17) ## finding (22)
- [1.3.4 推理契约验证与发布准备](findings/2026-09-09-134-thinking-contracts-validation.md) `finding:2026-09-09-134-thinking-contracts-validation` - [1.3.4 推理契约验证与发布准备](findings/2026-09-09-134-thinking-contracts-validation.md) `finding:2026-09-09-134-thinking-contracts-validation`
- [1.3.5 T2/T3/T4 验收证据:36 列遥测、失败终态、PG 存储兼容与变异矩阵](findings/2026-09-09-135-call-observability-validation.md) `finding:2026-09-09-135-call-observability-validation` - [1.3.5 T2/T3/T4 验收证据:36 列遥测、失败终态、PG 存储兼容与变异矩阵](findings/2026-09-09-135-call-observability-validation.md) `finding:2026-09-09-135-call-observability-validation`
- [1.3.5 发布完成与外部验收:合并后门、registry 产物与页面亲查](findings/2026-09-09-135-release-completion.md) `finding:2026-09-09-135-release-completion` - [1.3.5 发布完成与外部验收:合并后门、registry 产物与页面亲查](findings/2026-09-09-135-release-completion.md) `finding:2026-09-09-135-release-completion`
- [1.3.6 可选调用期限与取消结算验证记录](findings/2026-09-10-136-call-deadline-validation.md) `finding:2026-09-10-136-call-deadline-validation`
- [1.3.8 流式中段沉默:净口径与中段对冲验证报告](findings/2026-09-11-streaming-stall-validation.md) `finding:2026-09-11-streaming-stall-validation`
- [2026-07-20-m2-soak-workload](findings/2026-07-20-m2-soak-workload.md) `finding:2026-07-20-m2-soak-workload` - [2026-07-20-m2-soak-workload](findings/2026-07-20-m2-soak-workload.md) `finding:2026-07-20-m2-soak-workload`
- [2026-07-21-m25-acceptance](findings/2026-07-21-m25-acceptance.md) `finding:2026-07-21-m25-acceptance` - [2026-07-21-m25-acceptance](findings/2026-07-21-m25-acceptance.md) `finding:2026-07-21-m25-acceptance`
- [2026-07-21-p6-soak-baseline](findings/2026-07-21-p6-soak-baseline.md) `finding:2026-07-21-p6-soak-baseline` - [2026-07-21-p6-soak-baseline](findings/2026-07-21-p6-soak-baseline.md) `finding:2026-07-21-p6-soak-baseline`
- [2026-07-22-m4-acceptance](findings/2026-07-22-m4-acceptance.md) `finding:2026-07-22-m4-acceptance` - [2026-07-22-m4-acceptance](findings/2026-07-22-m4-acceptance.md) `finding:2026-07-22-m4-acceptance`
- [2026-07-22-p7-ocr-soak](findings/2026-07-22-p7-ocr-soak.md) `finding:2026-07-22-p7-ocr-soak` - [2026-07-22-p7-ocr-soak](findings/2026-07-22-p7-ocr-soak.md) `finding:2026-07-22-p7-ocr-soak`
- [2026-09-11-streaming-stall-hedging-research](findings/2026-09-11-streaming-stall-hedging-research.md) `finding:2026-09-11-streaming-stall-hedging-research`
- [2026-10-01-issue29-license-audit](findings/2026-10-01-issue29-license-audit.md) `finding:2026-10-01-issue29-license-audit`
- [issue #16/#17 实测: M3 推理正常,失效的是推理的可观测信号](findings/2026-08-25-thinking-observability-regression.md) `finding:2026-08-25-thinking-observability-regression` - [issue #16/#17 实测: M3 推理正常,失效的是推理的可观测信号](findings/2026-08-25-thinking-observability-regression.md) `finding:2026-08-25-thinking-observability-regression`
- [issue #18 实测: 偶发红的是安全网本身,不是被测脚本](findings/2026-08-26-issue18-shared-pg-test-isolation.md) `finding:2026-08-26-issue18-shared-pg-test-isolation` - [issue #18 实测: 偶发红的是安全网本身,不是被测脚本](findings/2026-08-26-issue18-shared-pg-test-isolation.md) `finding:2026-08-26-issue18-shared-pg-test-isolation`
- [issue #24 长尾对冲请求与裸生成时间验证报告](findings/2026-09-10-24-hedged-requests-validation.md) `finding:2026-09-10-24-hedged-requests-validation`
- [M2 verifier 三项 Important 补齐(不变量接线/网关保护/P3 验收)](findings/m2-verifier-fixes.md) `finding:m2-verifier-fixes` - [M2 verifier 三项 Important 补齐(不变量接线/网关保护/P3 验收)](findings/m2-verifier-fixes.md) `finding:m2-verifier-fixes`
- [M2 真实数据压测: 场景矩阵与数据清单](findings/m2-soak-workload.md) `finding:m2-soak-workload` - [M2 真实数据压测: 场景矩阵与数据清单](findings/m2-soak-workload.md) `finding:m2-soak-workload`
- [M2.5 验收: P6 同场景 58.1% → 98.96%](findings/m25-acceptance.md) `finding:m25-acceptance` - [M2.5 验收: P6 同场景 58.1% → 98.96%](findings/m25-acceptance.md) `finding:m25-acceptance`
@@ -68,10 +77,11 @@
- [P7 OCR soak 验收: 99.73% 与 13 不变量全 PASS](findings/p7-ocr-soak.md) `finding:p7-ocr-soak` - [P7 OCR soak 验收: 99.73% 与 13 不变量全 PASS](findings/p7-ocr-soak.md) `finding:p7-ocr-soak`
- [推理开关与 reasoning_tokens: 供应商实测与业界做法](findings/2026-08-02-thinking-switch-and-reasoning-tokens.md) `finding:2026-08-02-thinking-switch-and-reasoning-tokens` - [推理开关与 reasoning_tokens: 供应商实测与业界做法](findings/2026-08-02-thinking-switch-and-reasoning-tokens.md) `finding:2026-08-02-thinking-switch-and-reasoning-tokens`
## plan (38) ## plan (44)
- [1.3.4 推理契约实施计划](plans/2026-09-09-134-thinking-contracts.md) `plan:2026-09-09-134-thinking-contracts` - [1.3.4 推理契约实施计划](plans/2026-09-09-134-thinking-contracts.md) `plan:2026-09-09-134-thinking-contracts`
- [1.3.5 逻辑调用统计与结构化失败诊断实施计划](plans/2026-09-09-135-call-observability.md) `plan:2026-09-09-135-call-observability` - [1.3.5 逻辑调用统计与结构化失败诊断实施计划](plans/2026-09-09-135-call-observability.md) `plan:2026-09-09-135-call-observability`
- [1.3.6 可选调用期限与取消结算修复实施计划](plans/2026-09-10-136-call-deadline.md) `plan:2026-09-10-136-call-deadline`
- [1.3.8 流式中段沉默:计量口径与对冲实施计划](plans/2026-09-11-streaming-stall.md) `plan:2026-09-11-streaming-stall`
- [2026-07-20-m1-core-plan](plans/2026-07-20-m1-core-plan.md) `plan:2026-07-20-m1-core-plan` - [2026-07-20-m1-core-plan](plans/2026-07-20-m1-core-plan.md) `plan:2026-07-20-m1-core-plan`
- [2026-07-20-m2-distributed-plan](plans/2026-07-20-m2-distributed-plan.md) `plan:2026-07-20-m2-distributed-plan` - [2026-07-20-m2-distributed-plan](plans/2026-07-20-m2-distributed-plan.md) `plan:2026-07-20-m2-distributed-plan`
- [2026-07-21-m25-resilience-plan](plans/2026-07-21-m25-resilience-plan.md) `plan:2026-07-21-m25-resilience-plan` - [2026-07-21-m25-resilience-plan](plans/2026-07-21-m25-resilience-plan.md) `plan:2026-07-21-m25-resilience-plan`
@@ -90,12 +100,15 @@
- [2026-09-04-reasoning-effort](plans/2026-09-04-reasoning-effort.md) `plan:2026-09-04-reasoning-effort` - [2026-09-04-reasoning-effort](plans/2026-09-04-reasoning-effort.md) `plan:2026-09-04-reasoning-effort`
- [est_tokens 解耦实施计划](plans/est-tokens-decoupling.md) `plan:est-tokens-decoupling` - [est_tokens 解耦实施计划](plans/est-tokens-decoupling.md) `plan:est-tokens-decoupling`
- [issue #18 实现计划: 权限边界替代行数快照 + --table 锁死目标](plans/2026-08-26-issue18-pg-test-isolation.md) `plan:2026-08-26-issue18-pg-test-isolation` - [issue #18 实现计划: 权限边界替代行数快照 + --table 锁死目标](plans/2026-08-26-issue18-pg-test-isolation.md) `plan:2026-08-26-issue18-pg-test-isolation`
- [issue #24 长尾对冲请求与裸生成时间实施计划](plans/2026-09-10-24-hedged-requests.md) `plan:2026-09-10-24-hedged-requests`
- [issue #8 实施计划: stall 非生产性等待口径](plans/issue8-stall-budget-plan.md) `plan:issue8-stall-budget-plan` - [issue #8 实施计划: stall 非生产性等待口径](plans/issue8-stall-budget-plan.md) `plan:issue8-stall-budget-plan`
- [M1 核心里程碑实现计划](plans/m1-core-plan.md) `plan:m1-core-plan` - [M1 核心里程碑实现计划](plans/m1-core-plan.md) `plan:m1-core-plan`
- [M2 分布式实现计划](plans/m2-distributed.md) `plan:m2-distributed` - [M2 分布式实现计划](plans/m2-distributed.md) `plan:m2-distributed`
- [M2.5 治理韧性实现计划](plans/m25-resilience.md) `plan:m25-resilience` - [M2.5 治理韧性实现计划](plans/m25-resilience.md) `plan:m25-resilience`
- [M3 OCR 实现计划](plans/m3-ocr.md) `plan:m3-ocr` - [M3 OCR 实现计划](plans/m3-ocr.md) `plan:m3-ocr`
- [M4 迁移实现计划(T0-T14)](plans/m4-migration.md) `plan:m4-migration` - [M4 迁移实现计划(T0-T14)](plans/m4-migration.md) `plan:m4-migration`
- [OCR 整份文档解析实现计划(#34)](plans/2026-10-21-issue34-ocr-document.md) `plan:2026-10-21-issue34-ocr-document`
- [OpenAI消息保真实现计划](plans/2026-10-01-issue29-openai-fidelity.md) `plan:2026-10-01-issue29-openai-fidelity`
- [plan-issue14-admission-wait-policy](plans/plan-issue14-admission-wait-policy.md) `plan:plan-issue14-admission-wait-policy` - [plan-issue14-admission-wait-policy](plans/plan-issue14-admission-wait-policy.md) `plan:plan-issue14-admission-wait-policy`
- [响应可观测字段扩展实现计划](plans/response-observability-fields.md) `plan:response-observability-fields` - [响应可观测字段扩展实现计划](plans/response-observability-fields.md) `plan:response-observability-fields`
- [实现计划: HTTP 错误响应体留存(Issue #10)](plans/issue10-error-body-retention-plan.md) `plan:issue10-error-body-retention-plan` - [实现计划: HTTP 错误响应体留存(Issue #10)](plans/issue10-error-body-retention-plan.md) `plan:issue10-error-body-retention-plan`
@@ -106,18 +119,22 @@
- [实现计划: 遥测连接池的资源语义与生命周期(issue #15)](plans/plan-issue15-telemetry-pool-lifecycle.md) `plan:plan-issue15-telemetry-pool-lifecycle` - [实现计划: 遥测连接池的资源语义与生命周期(issue #15)](plans/plan-issue15-telemetry-pool-lifecycle.md) `plan:plan-issue15-telemetry-pool-lifecycle`
- [推理可观测性一等化实现计划(issue #16 + #17,发 1.3.1)](plans/2026-08-25-thinking-observability-plan.md) `plan:2026-08-25-thinking-observability-plan` - [推理可观测性一等化实现计划(issue #16 + #17,发 1.3.1)](plans/2026-08-25-thinking-observability-plan.md) `plan:2026-08-25-thinking-observability-plan`
- [推理开关能力建模与 reasoning_tokens 采集实施计划(issue #5 + #6)](plans/2026-08-02-thinking-capability.md) `plan:2026-08-02-thinking-capability` - [推理开关能力建模与 reasoning_tokens 采集实施计划(issue #5 + #6)](plans/2026-08-02-thinking-capability.md) `plan:2026-08-02-thinking-capability`
- [统一完成判定实现计划(#30/#31)](plans/2026-10-14-issue30-31-completion-verdict.md) `plan:2026-10-14-issue30-31-completion-verdict`
- [调用方自定义维度实现计划(issue #11)](plans/issue11-caller-dimensions.md) `plan:issue11-caller-dimensions` - [调用方自定义维度实现计划(issue #11)](plans/issue11-caller-dimensions.md) `plan:issue11-caller-dimensions`
- [采样参数透传实现计划(issue #4)](plans/sampling-params-plan.md) `plan:sampling-params-plan` - [采样参数透传实现计划(issue #4)](plans/sampling-params-plan.md) `plan:sampling-params-plan`
## review (1) ## review (7)
- [#34 OCR 整份文档解析 合并前验收与审查报告](reviews/2026-10-22-issue34-branch-review.md) `review:2026-10-22-issue34-branch-review`
- [2026-09-11-138-branch-review](reviews/2026-09-11-138-branch-review.md) `review:2026-09-11-138-branch-review`
- [2026-09-11-138-verifier](reviews/2026-09-11-138-verifier.md) `review:2026-09-11-138-verifier`
- [2026-09-11-streaming-stall-design-review](reviews/2026-09-11-streaming-stall-design-review.md) `review:2026-09-11-streaming-stall-design-review`
- [2026-09-11-streaming-stall-plan-review](reviews/2026-09-11-streaming-stall-plan-review.md) `review:2026-09-11-streaming-stall-plan-review`
- [保真缓存审查修复与1.4.0准备](reviews/2026-10-01-issue29-cache-review.md) `review:2026-10-01-issue29-cache-review`
- [整分支审查: issue #14 熔断等待档](reviews/issue14-branch-review.md) `review:issue14-branch-review` - [整分支审查: issue #14 熔断等待档](reviews/issue14-branch-review.md) `review:issue14-branch-review`
## schema (1) ## schema (1)
- [表结构: llm_calls(遥测 36 字段)](schemas/llm-calls.md) `schema:llm-calls` - [表结构: llm_calls(遥测 36 字段)](schemas/llm-calls.md) `schema:llm-calls`
## metric (2) ## metric (2)
- [OCR 治理调用成功率与错误分类分布](metrics/ocr-call-success.md) `metric:ocr-call-success` - [OCR 治理调用成功率与错误分类分布](metrics/ocr-call-success.md) `metric:ocr-call-success`
- [每次调用必录覆盖率(含缓存命中/失败/取消)](metrics/call-telemetry-coverage.md) `metric:call-telemetry-coverage` - [每次调用必录覆盖率(含缓存命中/失败/取消)](metrics/call-telemetry-coverage.md) `metric:call-telemetry-coverage`
+18
View File
@@ -160,3 +160,21 @@
- [2026-09-09 13:42 UTC] 重建索引: 100 篇页面 - [2026-09-09 13:42 UTC] 重建索引: 100 篇页面
- [2026-09-09 16:49 UTC] 重建索引: 101 篇页面 - [2026-09-09 16:49 UTC] 重建索引: 101 篇页面
- [2026-09-09 18:01 UTC] 重建索引: 102 篇页面 - [2026-09-09 18:01 UTC] 重建索引: 102 篇页面
- [2026-09-10 16:48 UTC] 新增边: plan:2026-09-10-24-hedged-requests --implements--> design:2026-09-10-24-hedged-requests-design
- [2026-09-10 16:48 UTC] 重建索引: 107 篇页面
- [2026-10-01 17:52 UTC] 新增 design: OpenAI消息保真设计 (design:2026-10-01-issue29-openai-fidelity-design)
- [2026-10-01 17:52 UTC] 新增 plan: OpenAI消息保真实现计划 (plan:2026-10-01-issue29-openai-fidelity)
- [2026-10-01 17:52 UTC] 新增边: plan:2026-10-01-issue29-openai-fidelity --implements--> design:2026-10-01-issue29-openai-fidelity-design
- [2026-10-01 17:52 UTC] 新增边: schema:llm-calls --implements--> design:2026-10-01-issue29-openai-fidelity-design
- [2026-10-01 17:52 UTC] 重建索引: 119 篇页面
- [2026-10-01 18:24 UTC] 新增 review: 保真缓存审查修复与1.4.0准备 (review:2026-10-01-issue29-cache-review)
- [2026-10-01 18:27 UTC] 新增边: review:2026-10-01-issue29-cache-review --informs--> design:2026-10-01-issue29-openai-fidelity-design
- [2026-10-01 18:27 UTC] 重建索引: 120 篇页面
- [2026-10-02 02:23 UTC] 重建索引: 120 篇页面
- [2026-10-22 02:35 UTC] 重建索引: 123 篇页面
- [2026-10-22 02:45 UTC] 新增边: plan:2026-10-21-issue34-ocr-document --implements--> design:2026-10-21-issue34-ocr-document-design
- [2026-10-22 02:45 UTC] 重建索引: 124 篇页面
- [2026-10-22 04:02 UTC] 重建索引: 124 篇页面
- [2026-10-22 04:23 UTC] 重建索引: 124 篇页面
- [2026-10-22 06:15 UTC] 新增边: review:2026-10-22-issue34-branch-review --informs--> plan:2026-10-21-issue34-ocr-document
- [2026-10-22 06:15 UTC] 重建索引: 125 篇页面
@@ -11,7 +11,7 @@ date: 2026-07-20
| 指标 | 确定性阈值/证据 | 实际 live 基线 | | 指标 | 确定性阈值/证据 | 实际 live 基线 |
| --- | --- | --- | | --- | --- | --- |
| 三个无推理入口成败行档位 NULL | embed、recognize_text、parse_layout 真实 client→emitter→临时 SQLite,非空失败/成功计数与 NULL 全部成立,契约要求100% | 待首次实际运行,不填伪百分比 | | 三个无推理入口成败行档位 NULL | embed、recognize_text、parse_layout、parse_document 真实 client→emitter→临时 SQLite,非空失败/成功计数与 NULL 全部成立,契约要求100% | 待首次实际运行,不填伪百分比 |
| chat 阳性 | 糖失败 auto、显式意图失败、nearest 成功实际档均精确匹配,要求100%;防恒 NULL 假绿 | 待首次实际运行 | | chat 阳性 | 糖失败 auto、显式意图失败、nearest 成功实际档均精确匹配,要求100%;防恒 NULL 假绿 | 待首次实际运行 |
| 四种行来源 | 真实成功=applied;失败=effective;cache_hit/scope终态=本次请求级 | 排除缓存/失败后才可做实际档分析 | | 四种行来源 | 真实成功=applied;失败=effective;cache_hit/scope终态=本次请求级 | 排除缓存/失败后才可做实际档分析 |
| live 覆盖 | 逐轮 PASS/FAIL/UNCOVERED、计划轮数与缺轮分别统计;必需单元不因 pytest exit 0 自动放行 | 未执行;UNKNOWN/缺轮/skip 不能记PASS | | live 覆盖 | 逐轮 PASS/FAIL/UNCOVERED、计划轮数与缺轮分别统计;必需单元不因 pytest exit 0 自动放行 | 未执行;UNKNOWN/缺轮/skip 不能记PASS |
@@ -31,3 +31,9 @@ date: 2026-07-20
| 存储兼容 | 真实 PG 沙箱: auto 追加十列、manual 裁剪写入、旧行新列恒 NULL、新旧进程混写共存 | 2026-09-09 实跑 30 passed(见下方证据索引) | | 存储兼容 | 真实 PG 沙箱: auto 追加十列、manual 裁剪写入、旧行新列恒 NULL、新旧进程混写共存 | 2026-09-09 实跑 30 passed(见下方证据索引) |
本版**新增十列**(见 schema:llm-calls),不新建表、不新增依赖。证据索引见 `findings/2026-09-09-135-call-observability-validation.md`;覆盖度真实基线仍待首次生产运行填入,不写伪百分比。 本版**新增十列**(见 schema:llm-calls),不新建表、不新增依赖。证据索引见 `findings/2026-09-09-135-call-observability-validation.md`;覆盖度真实基线仍待首次生产运行填入,不写伪百分比。
## #29 验收增量(2026-10-01)
复用指标及 llm_calls,不另建表:error→正常 attempts=2、tool-only attempts=1、
cache_hit attempts=0;协议失败不写缓存,取消不得遗留 permit/task。
确定性证据位于 `tests/unit/test_openai_fidelity.py`;完整协议不新增遥测正文列。
+3 -3
View File
@@ -7,11 +7,11 @@ date: 2026-07-21
# OCR 治理调用成功率与错误分类分布 # OCR 治理调用成功率与错误分类分布
- **数据源**: `llm_calls` 表(复用,无新表;M3 设计 §7)。OCR 行特征: `provider="monkey"`、tokens=0、cost=NULL、ttft/inter=NULL、cache_hit=0;`messages` 为 `<ocr:text|layout image_bytes=N>` 占位(图像 bytes 绝不入库),`response` 为 text 截断 200 字 / `<elements n=X pages=Y>` 摘要。 - **数据源**: `llm_calls` 表(复用,无新表;M3 设计 §7)。OCR 行特征: `provider="monkey"`、tokens=0、cost=NULL、ttft/inter=NULL、cache_hit=0;`messages` 为 `<ocr:text|layout|document image_bytes=N>` 占位(图像 bytes 绝不入库),`response` 为 text 截断 200 字 / `<elements n=X pages=Y>` 摘要。
- **埋点位置**(唯一路径,禁止旁路): `src/polygateway/ocr.py` OcrClient 治理循环内经 `TelemetryEmitter.emit_attempt`(单 helper 铁律),每次尝试(成功/失败/取消)必录;写失败 warning 降级不冒泡。 - **埋点位置**(唯一路径,禁止旁路): `src/polygateway/ocr.py` OcrClient 治理循环内经 `TelemetryEmitter.emit_attempt`(单 helper 铁律),每次尝试(成功/失败/取消)必录;写失败 warning 降级不冒泡。
- **指标口径**: - **指标口径**:
- 调用成功率 = `error IS NULL` 行数 / 总行数(按 provider="monkey" 过滤,窗口按 run/日); - 调用成功率 = `error IS NULL` 行数 / 总行数(按 provider="monkey" 过滤,窗口按 run/日);
- 错误分类分布 = `error` 非空行按异常类名前缀(Transient/SourceDead/RequestRejected/ResultInvalid/CircuitOpen/AllSourcesExhausted)归组计数; - 错误分类分布 = `error` 非空行按异常类名前缀(Transient/SourceDead/RequestRejected/ResultInvalid/CircuitOpen/AllSourcesExhausted)归组计数;
- 端点维度经 `messages` 占位前缀(`ocr:text` vs `ocr:layout`)区分; - 端点维度经 `messages` 占位前缀(`ocr:text` / `ocr:layout` / `ocr:document`)区分,或直接用 `operation` 列(`recognize_text` / `parse_layout` / `parse_document`);文档行的 `response` 摘要为 `<document elements=N pages=M md_chars=K>`,**不含正文**;
- 延迟 = `latency_ms` 分位数(text 与 layout 分列——layout 含两段 HTTP,显著更慢)。 - 延迟 = `latency_ms` 分位数(text、layout、document 分列——后两者含两段 HTTP,显著更慢;document 随页数增长,约 1.5–4 s/页[2026-10-21 实测:2 页 3.5–8 秒、4 页 6.2–6.4 秒;样本极少、无争用,非 SLA],不要与 layout 混算分位数)。
- **评估基线**: 待 P7 OCR soak 首跑后建立(健康双真源池预期成功率 ≥98%,故障池口径阈值在 P7 计划冻结);单调用延迟基线以 integration 实测为初值(2026-07-21 手测: /ocr/text 约 3-5s,/parse 约 5-15s)。 - **评估基线**: 待 P7 OCR soak 首跑后建立(健康双真源池预期成功率 ≥98%,故障池口径阈值在 P7 计划冻结);单调用延迟基线以 integration 实测为初值(2026-07-21 手测: /ocr/text 约 3-5s,/parse 约 5-15s)。
+16
View File
@@ -202,3 +202,19 @@ stack = ExtractionProviderStack(
| **AIMD 自适应并发**(库常量: 每源初始 8、429 削减 ×0.5、成功 +1/limit、下限 1、ceiling=max(64, 源级 max_concurrency)) | 冷启动每源并发从"无上限"变为 8 起步爬升——高吞吐下游首分钟吞吐低于旧行为;无 env 开关(韧性常量,同 jitter 系数先例),需要禁用者构造函数注入自定义 pacer | | **AIMD 自适应并发**(库常量: 每源初始 8、429 削减 ×0.5、成功 +1/limit、下限 1、ceiling=max(64, 源级 max_concurrency)) | 冷启动每源并发从"无上限"变为 8 起步爬升——高吞吐下游首分钟吞吐低于旧行为;无 env 开关(韧性常量,同 jitter 系数先例),需要禁用者构造函数注入自定义 pacer |
| 连败通道受健康证据抑制(窗口样本 ≥ min_calls 且失败率 < fail_rate 时 5 连败不开路) | CHS "连败必开"在高流量健康源上不再成立(防随机噪声误熔唯一好源);冷启动/低流量语义不变 | | 连败通道受健康证据抑制(窗口样本 ≥ min_calls 且失败率 < fail_rate 时 5 连败不开路) | CHS "连败必开"在高流量健康源上不再成立(防随机噪声误熔唯一好源);冷启动/低流量语义不变 |
| 结构化重问缺省 1→2 | 解析失败时最多多一跳成本;`PGW_STRUCTURED_MAX_RETRIES=1` 可显式还原 | | 结构化重问缺省 1→2 | 解析失败时最多多一跳成本;`PGW_STRUCTURED_MAX_RETRIES=1` 可显式还原 |
## 1.3.10 影响核对(2026-10-14,#30/#31)
CHS 已把 `ResultInvalidError` 作终态(坏结果≠坏服务,熔断记成功),1.3.10 文本 length/过滤且无正文落该分类与既有语义同向,worker `_TERMINAL` 分支无需改;缺 `[DONE]` 而完整的流从此直接成功。
## 1.3.11 CHSAnalyzer3 的整份文档消费路径(2026-10-22,#34)
CHSAnalyzer3 要把几百篇医学论文 PDF(每篇约 8–15 页,并发 ≤2,与医院线共用 MonkeyOCR)整份转成带页码的正文与表格。库侧提供 `OcrClient.parse_document`(设计 `designs/2026-10-21-issue34-ocr-document-design.md`):
| 事项 | 结论 |
| --- | --- |
| 入口 | `await ocr.parse_document(pdf_bytes, tenant_id=..., meta=...)`;结果 `elements[]`(`type`/`page_index`/`bbox`/`text`)、`page_sizes`、`markdown`。`parse_layout` 医院线路径**不动** |
| 表格 | `type=="table"` 的元素 `text` 是 HTML 表体;**表格脚注、页眉页脚、页码在服务端即被丢弃**(显著性/缩写说明会缺失),库无从补偿——抽取逻辑不要假设能拿到 |
| 部署 | 文档线用独立 scope(如 `OCR_DOC`),`MAX_CONCURRENCY=2`、`RETRY__MAX_ATTEMPTS=2`、`CALL_DEADLINE_S ≤ PGW_LEASE_TTL_S`、`TIMEOUT_S` 按最大页数估算;配方见 README「1.3.11 OCR 整份文档解析」。若服务为 sync-only 后端,每实例并发 1 并错峰,避免队头阻塞医院线 |
| 坏文件 | 无法识别的字节本地拒绝;截断/伪造 PDF 的已知确定性 500 归 `RequestRejectedError`(`status_code=500`),不重试、不计熔断。**页数过滤、可读性预检归调用方** |
| ⚠️ 待确认(H3) | 本文件 §9 记录 CHSAnalyzer 把库侧「服务不可用」一族(`GatewayUnavailableError`:`CircuitOpenError`/`AllSourcesExhausted`)翻译成 `ProviderUnavailableError` → arq 延期重投且不耗业务失败预算。**CHSAnalyzer3 是否沿用该语义未向下游证实**: 若沿用,一份永久损坏的 PDF 若落入 `AllSourcesExhausted(retry_exhausted)`(例如共享分类对未收录的确定性 500 仍归瞬时,见 #35)会被无限延期——请在 CHSAnalyzer3 侧为「同一文档连续 N 次延期」设上限 |
+4
View File
@@ -170,3 +170,7 @@ loop = AgentLoop(client, max_steps=...,
| R7 | 内存限流在多进程(arq)下的口径须在文档/配置中显式警示(B7 过渡期语义),避免误配全局限额 | M1 | | R7 | 内存限流在多进程(arq)下的口径须在文档/配置中显式警示(B7 过渡期语义),避免误配全局限额 | M1 |
以上 G1/G2 若按本文方向解决,GovDoc 全量迁移中业务代码(agent/workflow/govdoc api)对治理层的消费**零改动**,改动收敛于: 装配净新增、types.py/protocols.py/test_imports.py 三处改写、`.env` 改键。 以上 G1/G2 若按本文方向解决,GovDoc 全量迁移中业务代码(agent/workflow/govdoc api)对治理层的消费**零改动**,改动收敛于: 装配净新增、types.py/protocols.py/test_imports.py 三处改写、`.env` 改键。
## 1.3.10 影响核对(2026-10-14,#30/#31)
装配处 `retryable_exceptions=(TransientError, AllSourcesExhausted)`(§4)**不含** `ResultInvalidError`:文本 length/过滤且无正文改抛它后,AgentLoop 步级重试**不再**重抽该步——这正是本次修复的意图(确定性结局重抽只花钱)。业务侧若想对 length 截断做"加大 max_tokens 再试",须显式 except `ResultInvalidError` 并按异常消息原因码 `length_without_content` 分支;其余缺 `[DONE]` 而完整的流(#31)从此直接成功。
@@ -200,3 +200,7 @@ ocr = FrameOcrAdapter(OcrTextPort_from_env()) # 业务适配器包装库 OCR 端
--- ---
*不确定项声明*: 库侧 `from_env(role=...)`、OCR 装配工厂的具体 API 名称以 M1/M3 设计文档定稿为准,本文 §5.2/§6 中相应伪代码届时同步修订;`telemetry.db` 是否存在项目外的离线分析脚本消费方未能实测(项目内 grep 无读取方)。 *不确定项声明*: 库侧 `from_env(role=...)`、OCR 装配工厂的具体 API 名称以 M1/M3 设计文档定稿为准,本文 §5.2/§6 中相应伪代码届时同步修订;`telemetry.db` 是否存在项目外的离线分析脚本消费方未能实测(项目内 grep 无读取方)。
## 1.3.10 影响核对(2026-10-14,#30/#31)
AgentLoop 注入的 `(TransientError, AllSourcesExhausted)`(§4 点 3)**不含** `ResultInvalidError`:文本 length/过滤且无正文改抛它后,步级重试不再重抽该步(意图如此)。需要"加大 max_tokens 再试"的业务须显式 except 并按原因码 `length_without_content` 分支。§保留项 3"缺 `[DONE]` 判瞬时、不写缓存"在 1.3.10 收窄为"无 finish 或 finish 后无实测 usage 才算截断";finish+实测 usage+干净 EOF 视完整。
@@ -0,0 +1,270 @@
---
type: plan
node_id: plan:2026-09-10-136-call-deadline
title: "1.3.6 可选调用期限与取消结算修复实施计划"
date: 2026-09-10
---
# 1.3.6 可选调用期限与取消结算修复实施计划
> 设计:`research-wiki/designs/2026-09-09-136-call-budgets-design.md`,**人类于 2026-09-10 正式批准**(§9 七项批准项全数获批,H3 取 (a′):取消结算修复并入本版而非另立前置 issue)。
> 计划审核门:Claude 自审 + 独立模型审查;plan 无人类门,审毕直接执行。
> 目标:① 修好取消路径的 TPM 结算(§6.3 矩阵 S1–S7);② 给一次逻辑调用一条**可选**墙钟硬边界(issue #22),缺省 `None` 时行为逐字等于 1.3.5;③ 修 `Retry-After` 非有限值防御缺口。
> 方案:设计 §3 方案 A——三个公开边界各一次 `asyncio.timeout`,配**局部变量身份比较**判据(探针实证:只看 `cm.expired()` 会把清理期自抛的 `TimeoutError` 误标成 deadline)。
> 技术:Python 3.12+、asyncio、frozen dataclass、pytest + FakeClock + 真实 `asyncio.Event`、真实实验室 Redis、ruff、import-linter。
> 基线 HEAD:`d2455e8`(分支 `feature/1.3.6-call-budgets`;工作区仅 `CLAUDE.md` 既有 markdown 差异、未跟踪 `.pi/` 与本轮两份文档,前两者一律不动、不暂存)。
**不实现 issue #24(长尾对冲)**:未获批准,任何提交、测试与文档均不得出现 hedge/对冲机制,也不得声称本版缓解 #24。
## 1. 边界、授权与执行纪律
| 项目 | 固定边界 |
| --- | --- |
| 唯一 writer | 一工作区一 writer;父会话负责前台委派与审核派发。1.3.X 合并/发布授权沿用;跨到 1.4、新公共面变化或验证豁免须停下确认 |
| 公共面 | 只做设计 §9 已批准四项:新错误类 `CallDeadlineExceeded`、新配置键 `{SCOPE}__CALL_DEADLINE_S`、三个 client 构造参数 + 四个公开方法 keyword-only 参数、取消路径结算口径。**不新增其它键/端口方法/遥测列** |
| 记账边界 | 只改**取消路径**的 `settle()` 入参取值(设计 §6.3 S3/S5/S7);成功、`RequestRejected`、`ResultInvalid`、`SourceDead` 四条既有路径与**未分类异常逃逸路径(S8,仍 `0`)**的结算值逐字不变;实现只能用**函数内局部阶段变量**,不得新增公开参数;限流 Lua、`Permit` 端口签名、幂等语义一律不动 |
| 依赖铁律 | 新模块 `deadline.py` 只 import stdlib + `errors.py`;`middleware/` 仍只依赖端口与内核;import-linter 契约新增一层执法 |
| 取消 | `CancelledError` 永不吞没;不引入 `shield`、不开后台任务;清理仍在 `finally`,允许超出期限 |
| 降级方向 | 限流/熔断后端仍 fail-closed;遥测/缓存仍 warning 降级;非法期限值 → **当场 `ValueError`**(装配错误不属降级面) |
| 证据与秘密 | 不打印 `.env`、token、Authorization;不提交 `.pi/`、`tests/outputs/`;命令输出只记路径、状态与退出码 |
| 证据复用 | 复用既有 FakeClock / 假 transport / `settle_and_release` 出口 / 限流契约套件 / 真实 Redis 跨连接用例;**不重跑模型能力矩阵**,本版零付费调用 |
Skill 纪律:T0 已执行 `writing-plans`;T1–T3 行为变更执行 `test-driven-development`(先失败后通过的证据须落在本会话工具输出里);每次提交执行 `commit`(英文祈使标题、无 AI 签名、显式路径暂存);T4 前执行 `requesting-code-review` 与 `verification-before-completion`;异常先 `systematic-debugging` 定根因。
## 2. 文件职责与不变接缝
| 动作 | 精确路径 | 职责 |
| --- | --- | --- |
| 新建 | `src/polygateway/deadline.py` | `ensure_call_deadline()` 值域校验 + `with_call_deadline()` 单一硬边界(§3.1) |
| 修改 | `src/polygateway/errors.py` | 追加 `CallDeadlineExceeded(PolyGatewayError)`(§3.2);`SCOPE_REASONS`/四分类**不动** |
| 修改 | `src/polygateway/__init__.py` | `from polygateway.errors import ... CallDeadlineExceeded`;`__all__` 插在 `"CallStats"` 之后、`"CircuitOpenError"` 之前(现读 `:61-62`;该列表并非全字母序,头部 `DEFAULT_PROFILES`/`EFFORT_ORDER`/`Effort` 是既有例外,**不得顺手重排**) |
| 修改 | `src/polygateway/config.py` | `GatewaySettings` 末尾追加 `call_deadline_s: float \| None = None`;`_load_call_deadline()`;`_validate_call_deadline()` 进 `__post_init__`(§3.3) |
| 修改 | `src/polygateway/client.py` | `__init__` 追加 `call_deadline_s`(入口即校);`chat()` 追加 per-call 参数;`:398` 包裹;`from_settings` 透传 |
| 修改 | `src/polygateway/embedding.py` | 同上三处(`:209` 包裹整次 `_embed_all`);`_attempt` 结算矩阵(§3.5) |
| 修改 | `src/polygateway/ocr.py` | `__init__`/两个公开方法/`_call` 两级透传;`:274` 包裹 `_run`;`:449 settle_and_release(permit, 0)` **保持 0** |
| 修改 | `src/polygateway/middleware/retry.py` | `_attempt` 结算矩阵(§3.5:`actual` 初值仍 `0` + 局部 `settlement_known`);`__call__` 循环、`backoff_delay`、`StallClock` 一字不动 |
| 修改 | `src/polygateway/transports/openai_compat.py` | `_parse_retry_after`(`:111-119`)增 `math.isinf` 判据 + 一条 warning + 必填私有 kw `source_name`;同步唯一调用处 `_translate_429`(`:140`)(§3.6) |
| 修改 | `pyproject.toml` | import-linter layers 在 `"polygateway.thinking"` 与 `"polygateway.providers : polygateway.sources"` 之间插入 `"polygateway.deadline"` 一行 |
| 新建 | `tests/unit/test_deadline.py` | `deadline.py` 的值域与五种形态区分(§5 批次 A/B) |
| 修改 | `tests/unit/test_retry.py` | 取消结算红绿(S1/S3/S4/S5)+ `FakeTransport` 加 `entered` Event |
| 修改 | `tests/unit/test_embedding.py` | 取消结算(S7)、多批共享一份期限 |
| 修改 | `tests/unit/test_ocr_client.py` | 取消结算恒 0(S6)、两个入口的期限与 per-call 校验 |
| 修改 | `tests/unit/test_client.py` | chat 期限命中、终态遥测行、已计费成功被丢弃、注入钟无关 |
| 修改 | `tests/unit/test_config.py` | 键未设/非法值 × env / 直接构造 / `dataclasses.replace` / 三个 `__init__` 直传 |
| 修改 | `tests/unit/test_openai_compat.py` | F1 四类取值 |
| 修改 | `tests/integration/test_redis_cross_connection.py` | 真实 Redis 上的取消结算契约(复用既有 `clients`/`_limiter`/`_client`/`ScriptedTransport`,**不改 Lua、不改契约套件**) |
| 修改 | `CHANGELOG.md`、`README.md`、`.env.example` | 新键、新异常、对外承诺三句话(§4 C4) |
| 新建 | `research-wiki/findings/2026-09-10-136-call-deadline-validation.md` | 红绿、命令、豁免索引,≤300 行 |
**不改**:`ports.py`(`Permit.settle` 签名与语义不动)、`middleware/admission.py`(`settle_and_release` 逐字不动)、`middleware/ratelimit.py`、`middleware/breaker.py`、`middleware/structured.py`、`middleware/cache.py`、`middleware/telemetry.py`、`telemetry/schema.py`(零新增列)、`backends/**`(含全部 Lua)、`sources.py`、`streaming.py`、`types.py`、`transports/monkey_ocr.py`(`:58` 不解析 `Retry-After`)、`tests/contracts/**`(复用现有 settle 契约,只跑不改)。若实施时发现必须突破本清单,先说明最小原因交父会话核定。
## 3. 跨任务接口(可执行定义,禁止占位)
### 3.1 `src/polygateway/deadline.py`
```python
def ensure_call_deadline(value: object, origin: str) -> float | None:
"""全装配路径共用的值域校验: None 或有限正数, 否则 ValueError(消息含 origin)。"""
if value is None:
return None
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValueError(f"{origin} 必须是 None 或有限正数秒: {value!r}") # bool 先判
v = float(value)
if not math.isfinite(v) or v <= 0:
raise ValueError(f"{origin} 必须是有限正数秒: {value!r}") # NaN/inf/0/负
return v
async def with_call_deadline[T](aw: Awaitable[T], *, deadline_s: float | None, scope: str) -> T:
if deadline_s is None:
return await aw # 未启用: 不进上下文, 逐字旧路径
inner_timeout: BaseException | None = None
try:
async with asyncio.timeout(deadline_s) as cm:
try:
return await aw
except TimeoutError as exc:
inner_timeout = exc # 体内(含清理路径)自抛, 非本层期限
raise
except TimeoutError as exc:
if cm.expired() and exc is not inner_timeout:
raise CallDeadlineExceeded(scope=scope, deadline_s=deadline_s) from None
raise
```
三条实现红线:① **校验先于构造 awaitable**(否则非法值抛错时遗留未 await 协程 → `RuntimeWarning` + 资源不释放);② 只传**相对时长**,绝不把注入 `now` 加偏移换算成绝对截止时刻;③ 身份比较**不可退化**为只看 `cm.expired()`——探针 `/tmp/pgw_deadline_probe.py` E1/E2 实测:清理路径自抛的 `TimeoutError` 会被只看 `expired()` 的写法改标成 `CallDeadlineExceeded`;`__cause__` 启发式同样失效(内层 `asyncio.timeout` 的 `TimeoutError` 其 `__cause__` 也是 `CancelledError`)。本机制**不新增公共配置、不开后台任务、不改异常对象**。
### 3.2 `errors.py` 新类(唯一定义点)
```python
class CallDeadlineExceeded(PolyGatewayError):
"""调用方设定的整体期限到期; 不是网关不可用、也不是源故障。"""
def __init__(self, *, scope: str, deadline_s: float) -> None:
super().__init__(f"{scope} 调用期限 {deadline_s}s 到期")
self.scope = scope
self.deadline_s = deadline_s
```
无 `retry_after_s`(期限到期不含"何时可再试",给 `0.0` 会按既定语义指示下游立刻重打饱和渠道);不进 `SCOPE_REASONS`;不属四分类。`__init__.py` 导出后,三个边界既有的 `except PolyGatewayError` 自动接住并写终态行——**遥测零改动**。
### 3.3 配置(`config.py`)
| 项 | 精确定义 |
| --- | --- |
| 键名 | `{SCOPE}__CALL_DEADLINE_S`(两段式,`"LLM__CALL_DEADLINE_S".split("__")` 长度 **2** ≠ 4,故 `_load_sources`(函数定义 `:380`,判据行 `:384`)天然跳过,**不必**加进 `_RESERVED_SEGMENTS`) |
| loader | `_load_call_deadline(scope, env)`:`found = _first(env, f"{scope}__CALL_DEADLINE_S")`;`None → None`;否则 `ensure_call_deadline(_cast(found[1], "float", found[0]), found[0])`。**用 `_first` 不用 `_require`**(`_require` 会把未设当成配置缺失报错 = 破坏性变更);**origin 传实际命中的 env 键名 `found[0]`**,不传 `"GatewaySettings.call_deadline_s"`——否则 env 里写 `LLM__CALL_DEADLINE_S=0` 的人会拿到一条指向字段名的错误,在多 scope 部署里无法定位是哪个键(`_cast` 只能接住“不是数字”,`0`/负/`inf` 会穿过它) |
| 字段 | `GatewaySettings` **末尾**追加 `call_deadline_s: float \| None = None`(有默认值,不扰动既有位置构造;`EmbeddingSettings.gateway` / `OcrSettings.gateway` 自动继承) |
| 守卫 | `_validate_call_deadline()` 加入 `__post_init__`(`:186-194`)末位,实现体是 `object.__setattr__(self, "call_deadline_s", ensure_call_deadline(self.call_deadline_s, "GatewaySettings.call_deadline_s"))`——盖住直接构造与 `dataclasses.replace` 两条路;env 路已在 loader 里拿真键名报过错,此处重跑对合法值是幂等空操作 |
| 装配透传 | `GatewaySettings.from_env`(`:349`)返回字典加 `call_deadline_s=_load_call_deadline(scope_u, env)`;`GatewayClient.from_settings`(`client.py:449`)传 `settings.call_deadline_s`;`EmbeddingClient.from_settings`(`embedding.py:584`)与 `OcrClient.from_settings`(`ocr.py:631`)传 `gw.call_deadline_s`;三个 `from_env` 签名不变 |
| 不耦合 | **不校验** `call_deadline_s` 与 `timeout_s`/`stall_window_s` 的大小关系:期限短于单次超时是调用方的合法选择 |
### 3.4 三个接入点与 per-call 透传(唯一三处)
| 文件:行 | 改后 |
| --- | --- |
| `client.py:398` | `response = await with_call_deadline(self._handler(request), deadline_s=deadline, scope=self._scope)` |
| `embedding.py:209` | 同款包住 `self._embed_all(...)`(**整次调用一份**,N 批共享) |
| `ocr.py:274` | 同款包住 `self._run(...)` |
- 三处均在既有 `try` 之内、`_CallContext` 创建之后 → 到期照走 `except PolyGatewayError → emit_terminal_once`。
- `StructuredMW._run_ladder`(`structured.py:67-98`)与 `_embed_batch`(`embedding.py:295`)**严禁**新建 scope:同级重问/分批共享同一份期限,否则期限被轮数放大 N 倍。
- 三个 `__init__` 追加 keyword-only `call_deadline_s: float | None = None`,函数体首行即 `self._call_deadline_s = ensure_call_deadline(call_deadline_s, "<Class>(call_deadline_s=...)")`;`client.py` 需自存 `self._scope = scope`(现只传给 RetryMW,未自存)。
- 四个公开方法(`chat`/`embed`/`recognize_text`/`parse_layout`)追加 keyword-only `call_deadline_s: float | None = None`;`None` = 继承装配值,正数 = 本次覆盖,**不提供"本次关闭"**。取值一行:`deadline = self._call_deadline_s if call_deadline_s is None else ensure_call_deadline(call_deadline_s, "<method>(call_deadline_s=...)")`,位置在既有输入校验之列、`_CallContext` 创建**之前**。
- OCR 两个入口经 `recognize_text`/`parse_layout` → `_call` 形参透传;`_call` 内的校验须与 `image` 校验同列(`ocr.py:266-269`),即仍在 `_CallContext`(`:272`)之前。
- `embedding.py:205-217` 的 `texts == []` 早返回在 `try` 之前,天然落在期限之外——保持原样,测试显式记一笔。
### 3.5 取消结算矩阵落到代码(设计 §6.3 的唯一实现形态)
`middleware/retry.py::_attempt`(对照现读行号)。人类只批准了“**取消路径**的结算口径”这一条,故 `actual` 初值**不得**改成 `est`——那会把语义泛化到一切未分类异常(`RuntimeError`、`KeyError` 逃逸),属未批准范围。改用**局部阶段变量**:
```python
actual = 0 # 初值不动:未分类异常逃逸时仍逐字走 1.3.5 语义
settlement_known = False # 局部变量:该刻库是否已算出确定结算(不进任何签名)
```
| 现状行 | 现状 | 改后 |
| --- | --- | --- |
| `:281` | `actual = 0` | **保持 `0`**,紧随一行新增 `settlement_known = False`(局部阶段变量,带注释:只服务于取消分支的兜底取值,不进任何签名) |
| `:288` | `await self._transport.complete(...)` | 不动(S3 的“端口已开始”窗口就是它未返回的那段) |
| `:301` | `actual = source.effective_est_tokens()`(usage 不可得) | 值不变,其后置 `settlement_known = True` |
| `:303` | `actual = result.prompt_tokens + result.completion_tokens` | 值不变,其后置 `settlement_known = True`(S4;**真实 usage 恰为 0 也算已知**,取消不得覆写) |
| `:311` `RequestRejectedError` | 隐式 0 | 分支**首句**:`actual = 0; settlement_known = True`(逐字保住 1.3.5,且取消落在本分支 await 中途仍得 0) |
| `:315` `ResultInvalidError` | 隐式 0 | 同上(已计费的坏结果仍退全款属另一族缺口,本版不动) |
| `:320` `CancelledError` | 不动 `actual` | **唯一**新增赋值点,且在本分支首句:`if not settlement_known: actual = source.effective_est_tokens()`;`is_probe` / `_emit` / `raise` 三行原样 |
| `:325` 失败分支入口 | `dead = isinstance(exc, SourceDeadError)` | 完成该分支原有同步分类/选源反馈后,紧贴第一个 `await record_failure` 之前插入 `actual = 0 if dead else source.effective_est_tokens(); settlement_known = True`;不得前移到同步分类之前改变其异常结算 |
| `:335-336` | `if not dead: actual = est` | **删除**(已上移);非取消路径的最终值与 1.3.5 逐字相同,只是算得更早 |
| `:339-341` | `finally: pacer.leave(); settle_and_release(permit, actual)` | 一字不动 |
**“确定结算”的界桩就是上表的 await 位置**:失败分支的结算决定前移到两个记账 await 之前,故**取消发生在已知 `SourceDead` 之后时保留那个既有的 `0`**(源已判死就不该继续占额度)。**为何必须靠位置而不能只靠标志位**:`except asyncio.CancelledError` 与 `except (SourceDeadError, TransientError)` 是**同级**分支,落在后者块内 await 上的取消**不会**被前者接住,直接穿到 `finally`——那一刻 `actual` 是什么就结什么,标志位没有机会被读到。故失败分支必须在其**第一个 await 之前**就把 `actual` 定死。本版**不把 S5 泛化成“一切失败按 `est` 结算”**;`est` 只是“取消且结算未定”这一格的兜底值。
`embedding.py::_attempt` 同构:`:345` 保持 `actual = 0` 并新增 `settlement_known = False`;`:358`/`:360` 值不变、其后置 `True`;`:377`(`RequestRejected`/`ResultInvalid` 合并分支)首句 `actual = 0; settlement_known = True`;`:408` 失败分支在 `dead` 之后、`record_failure`(`:414`)之前插入 `actual = 0 if dead else est; settlement_known = True` 并删掉 `:414-415` 的 `if not dead:` 赋值;`:392` 取消分支首句加同款条件赋值;`:429-430 finally` 不动。
`ocr.py:449 settle_and_release(permit, 0)` **保持 0**:OCR 无 token 是事实而非"未知"(`ocr.py:9` 既有声明),不得改成 est,也不引入 `settlement_known`。
**防越界回归(必带)**:假 transport 抛 `RuntimeError`(不属四分类、无 except 接住)→ `tpm_used == 0` 且异常原样上抛;真实 usage 恰为 0 的成功 → `tpm_used == 0`。两条把“不得扩到未批准语义”钉成可回归的断言。
共享状态与探针一律不变:`pacer.leave()`、`permit.release()`、`breaker.release_probe()`(`retry.py:321-322`、`ocr.py:410-411`、`embedding.py:393-394`)、`mark_progress` 的调用点、次数与顺序全部逐字保留;**不新增任何公开参数**。
### 3.6 F1:`Retry-After` 非有限值(`transports/openai_compat.py:111-119`)
签名改为 `_parse_retry_after(raw: str | None, *, source_name: str) -> float | None`——`source_name` 是**必填 keyword-only 参数**(私有模块内函数,不属公共面,故不给默认值;漏传即 `TypeError`);**唯一调用处**是 `_translate_429`(`:140`),改传 `source_name=source.name`(该函数已持有 `source`,不需新参数)。
判据与告警(按已批设计 §6.1 原文精确定义,不得自行扩大):
| 输入形态 | 返回 | 日志 |
| --- | --- | --- |
| `inf` / `-inf` / `1e999`(`float()` 成功且 `math.isinf(seconds)`) | `None` | **一条 `logger.warning`**,只写源名与判据词(如 `retry_after_not_finite`),**不拼接、不截断、不打印原始头字符串** |
| `nan` | `None` | **无告警**:沿用既有 `seconds > 0` 恒假的值语义,本版**不为它新增分支、不改判据顺序** |
| HTTP-date / 空串 / 负数 / 不可解析 | `None` | 无告警(429 风暴下逐次告警会淹掉真信号) |
| 有限正数 | 该值 | 无 |
实现上只在 `float()` 成功后、`seconds > 0` 之前插一段 `if math.isinf(seconds): warning; return None`;`_translate_429` 的分类、`backoff_delay` 的 `max(delay, retry_after)` 取大逻辑一字不动(设计 §6.2:不夹 `backoff_max_s`)。
## 4. 任务与提交点(4 个原子提交)
### T0:设计批准状态与本计划(本任务,无代码)
产出:设计文档状态改批准 + §6.3 结算矩阵 + §5.2 五形态;本计划。不提交代码、不动测试。
### T1 → 提交 1 `fix: settle cancelled attempts against the source estimate`
1. **先红**:按 §5 批次 C 写 S3/S7 用例(`test_retry.py`、`test_embedding.py`),确认失败信息是 `tpm_used == 0 != 400`(不是构造错误);同批写 S5-dead 与 S8 两条**防越界**用例(实现前应已绿,作回归锁)。
2. 改 `middleware/retry.py::_attempt` 与 `embedding.py::_attempt`(§3.5:初值保 0 + 局部 `settlement_known`,失败分支结算决定上移到两个 await 之前),`ocr.py` 只补注释不改值;**不新增任何公开参数、不改未分类异常路径**。
3. **后绿**:新用例通过;`pytest tests/unit -q` 全绿(S1/S4/S5-dead/S6/S8 回归断言在批次 C 内一并落地)。
4. 真实 Redis:`tests/integration/test_redis_cross_connection.py` 新增取消结算用例(§5 批次 F),跑 `pytest tests/integration/test_redis_cross_connection.py -q`。
5. 暂存路径:`src/polygateway/middleware/retry.py`、`src/polygateway/embedding.py`、`src/polygateway/ocr.py`、三个测试文件。
### T2 → 提交 2 `feat: add an optional per-call wall-clock deadline`
1. 新建 `deadline.py`(§3.1)、`errors.py` 新类(§3.2)、`__init__.py` 导出、`pyproject.toml` layers 一行。
2. `config.py` 四处(字段/loader/守卫/`from_env`)、三个 client 的构造参数 + 公开方法参数 + 包裹点 + `from_settings` 透传(§3.3/§3.4)。
3. 先红后绿顺序:批次 A(`test_deadline.py` 值域)→ 批次 B(五形态)→ 批次 D(三链路命中与覆盖面)→ 批次 E(配置四条路)。
4. 回归门:`pytest tests/unit tests/contracts -q` 全绿且**未改一行既有断言**;`make lint`(含 import-linter 新层)通过。
5. 暂存路径:`src/polygateway/deadline.py`、`errors.py`、`__init__.py`、`config.py`、`client.py`、`embedding.py`、`ocr.py`、`pyproject.toml`、`tests/unit/test_deadline.py` 及四个改动测试文件。
### T3 → 提交 3 `fix: ignore non-finite Retry-After hints`
1. 先红:`tests/unit/test_openai_compat.py` 加 `inf`/`-inf`/`1e999`/`nan`/空/负/HTTP-date 七例,并加一例漏传 `source_name` 的 `TypeError`(批次 G)。
2. 改 `_parse_retry_after`:加必填私有 kw `source_name`、加 `math.isinf` 判据与一条 warning,同步唯一调用处 `_translate_429`(`:140`);跑该文件与 `tests/unit -q`。
3. 暂存:`src/polygateway/transports/openai_compat.py`、`tests/unit/test_openai_compat.py`。
### T4 → 提交 4 `docs: document the optional call deadline and cancellation settlement`
1. `CHANGELOG.md` 未发布段:三句强制措辞——**期限治理的是等待、返回时刻 = 期限 + 清理耗时(实测 5–7 倍)**;**到期不等于未产出、未计费**;**不配置即保持 1.3.5 语义**(纯 429 序列仍可能长等、有限大 `Retry-After` 仍照睡)。另记取消结算口径变化:**仅当取消发生在“端口已开始、结算尚未确定”时**按 `est` 保留预扣(方向为宁多扣不空退);已知结算(含真实 usage 恰为 0、已判 `SourceDead` 的 `0`)不被覆写,**未分类异常仍按 `0`**。另列"`except GatewayUnavailableError` 接不住新异常"。
2. `README.md` **四处同步(缺一不可,按行号定位)**:① 能力表(`:10-22` 区间)新增一行“调用期限”,措辞用 §5.3 三句;② “### 4. 业务侧异常处理”示例(`:185-195`)——该段 `except GatewayUnavailableError` **接不住** `CallDeadlineExceeded`,必须加一条 `except CallDeadlineExceeded` 分支并注明它无 `retry_after_s`;③ “哪些异常会到达调用方”表(`:466-476`)左列新增 `CallDeadlineExceeded` 行,并写明它**不属四分类、不属 `GatewayUnavailableError` 族**,只在显式配期限后才可能出现;④ 错误模型段补一句**有限大 `Retry-After` 残留**(能力表 `:15` 写的“尊重 Retry-After”仍成立:库不夹 `backoff_max_s`,服务端给 3600s 就睡 3600s,唯一制约手段是本版的调用期限;`inf`/`1e999` 自 1.3.6 起按无提示处理)。另:`.env.example` 在 `LLM__CIRCUIT_OPEN`(`:71`)之后加注释行 `# LLM__CALL_DEADLINE_S=`(缺省不启用,说明其治理对象是等待)。
3. `research-wiki/findings/2026-09-10-136-call-deadline-validation.md`:红绿证据、命令与退出码、豁免索引。
4. 独立验证(全新上下文 verifier)与整分支审查在本提交前完成;版本号与 wiki 同步留给发布清单(本计划不 bump、不发布)。
## 5. 测试矩阵 → 任务映射
**测试设施复用与"哪一份副本"的硬性核对**(历史坑,动手前必须核对):
| 事实 | 证据 | 纪律 |
| --- | --- | --- |
| `tests/unit/test_retry.py:35`、`tests/unit/test_embedding.py:176` 从 `tests.contracts.conftest` import `FakeClock` | 现读 | 改这一份即影响契约与两个单测文件 |
| `tests/unit/test_ocr_client.py:344` **自带一份同名 `FakeClock`** | 现读 | OCR 用例只吃这一份;给 OCR 加期限用例时不得误改 contracts 那份并以为生效 |
| `tests/unit/test_embedding.py:177` 从 `tests.unit.test_backpressure` import `BoundedSleep` | 现读 | 复用它做"轮询次数有界"断言,不新造 |
| 无 `tests/conftest.py` / `tests/unit/conftest.py` | `ls` 实测 | 新 fixture 只能进各文件本地,或复用 `tests/contracts/conftest.py`(已被 unit 直接 import) |
**取消白箱的确定性纪律**:既有取消用例用 `await asyncio.sleep(0.05)` 撞窗口(`test_retry.py:474`、`test_ocr_client.py:365`)——新用例**不得**沿用。做法:给 `test_retry.py::FakeTransport` 的 `"hang"` 分支加 `self.entered.set()`(构造期 `self.entered = asyncio.Event()`,3.10+ 不绑定 loop),用例 `await transport.entered.wait()` 后再 `task.cancel()`;embedding/OCR 的 `ScriptedEmbedTransport`/`ScriptedOcrTransport` 同款加一个 `entered`。既有用例不动。
| 批次 | 断言(→ 任务) | 落点 |
| --- | --- | --- |
| A 值域 | `None` 通过;`0`/负/`nan`/`inf`/`"1"`/`True`(bool 不得当 1 秒)/`object()` → `ValueError` 且消息含 origin(→T2) | `tests/unit/test_deadline.py` |
| B 形态区分 | ①到期 → `CallDeadlineExceeded`(`scope`/`deadline_s` 正确);②未到期内层自抛 `TimeoutError` → 原样上抛;③**到期后清理自抛 `TimeoutError`** → 原样上抛且**断言不是** `CallDeadlineExceeded`(钉住身份比较);④外部 `task.cancel()`(先于/晚于到期各一例)→ `CancelledError`;⑤擦边成功 → 正常返回且 `task.cancelling() == 0`;⑥到期窗口内体内先抛领域异常(同步自旋构造)→ 上抛该异常,**不断言必为 deadline**;⑦`deadline_s=None` → 逐字旧路径(→T2) | `tests/unit/test_deadline.py`(真实 loop 时钟,期限 0.05s、体 0.3s,4–10× 余量,不标 slow) |
| C 取消结算 | S3:`tpm=1000, est_tokens=400`、transport `hang`、`entered` 后取消 → `tpm_used == 400`(**红→绿核心**)且 `inflight == 0`;S4:假 gate 在 `record_success` 处 `set()` 后挂起 → 取消 → `tpm_used == 15`(真实 usage 未被覆盖);S1:熔断开路使 `pick` 走 `settle_and_release(permit, 0)` → `tpm_used == 0`;S5-dead:假 gate 在 `SourceDead` 的 `record_failure` 处挂起 → 取消 → `tpm_used == 0`(**不得**变 `est`);S5-transient:同位置但瞬时失败 → `tpm_used == est`;S6:OCR 源 `tpm=600`、transport `hang` → 取消 → `tpm_used == 0`;S7:embedding 同 S3;**S8 防越界**:假 transport 抛 `RuntimeError` → `tpm_used == 0` 且异常原样上抛;真实 usage 恰为 0 的成功 → `tpm_used == 0`;四条既有路径(成功/`SourceDead`/`RequestRejected`/`ResultInvalid`)结算值逐字不变(→T1) | `test_retry.py`、`test_embedding.py`、`test_ocr_client.py` |
| D 覆盖面 | 期限分别落在 ①退避 `sleep`(注入真 `asyncio.sleep`)②准入排队(配额满轮询)③结构化重问 ④embedding 多批 → 均抛 `CallDeadlineExceeded`;embedding 断言 **N 批共享一份**期限(总时长不随批数放大);`texts == []` 早返回不受期限影响(→T2) | `test_client.py`、`test_embedding.py`、`test_ocr_client.py` |
| D2 到期代价 | ①终态遥测:到期恰好一条 `event_kind='terminal_failure'`、`error_type='CallDeadlineExceeded'`,被取消的 attempt 行仍 `cancelled`,两行 `logical_call_id` 一致,列数不变;②清理不可越过 + 量化:假 permit/假 emitter 各注入已知 sleep → 返回时刻 ≈ 期限 + 已知清理时长(断言 > 期限的若干倍,不断言上界);③已计费成功被丢弃:假缓存后端 `set` 慢于期限 → 抛 deadline 且断言 transport **已成功调用一次**(→T2) | `test_client.py` |
| E 配置四条路 | 键未设 → `None`;非法值 × {env、`GatewaySettings(...)` 直接构造、`dataclasses.replace`、三个 client `__init__` 直传} 各一例 → `ValueError`;per-call 非法值抛错且**无 "coroutine was never awaited" 警告**(`pytest.warns` 反向断言 / `-W error::RuntimeWarning`);`call_deadline_s < timeout_s` 合法不报错;注入钟跳变 10^6 秒**不**触发期限,而期限触发时 `total_latency_ms` 仍取自注入钟(→T2) | `test_config.py`、`test_client.py` |
| F 真实 Redis | 复用 `tests/integration/test_redis_cross_connection.py` 的 `clients`/`_limiter`/`_client`/`ScriptedTransport(hang=True)`:源 `tpm=1000, est_tokens=400`,`inflight` 出现后取消 → `source_stats.tpm_used == 400` 且 `inflight == 0`。**不改 Lua、不改 `tests/contracts/`**;另跑既有 `pytest tests/contracts/test_limiter_contract.py -q`(memory+redis 双参数)证明后端算术未被触碰(→T1) | `tests/integration/test_redis_cross_connection.py` |
| G F1 | `inf`/`-inf`/`1e999` → `None` + 各一条 warning(断言日志**含源名、不含**原始字符串);`nan`/空/负/HTTP-date → `None` 且**无** warning(`nan` 仍走既有 `seconds > 0` 值语义,不新增分支);漏传 `source_name` → `TypeError`(钉住必填 kw);有限正数仍参与 `max(delay, retry_after)`;`insufficient_quota` 仍归 `SourceDead`(→T3) | `test_openai_compat.py` |
| H 未启用回归 | `call_deadline_s=None` 时 `tests/unit`、`tests/contracts` 全绿且**未改一行既有断言**(→T2 门) | 全套件 |
命令(全部 `conda run -n PolyGateway`,禁止接管道以免退出码失真):`pytest tests/unit -q`、`pytest tests/contracts -q`、`pytest tests/integration/test_redis_cross_connection.py -q`、`make lint`。真实网关 e2e 与 `-m slow` 变体本计划**不跑**,由发布清单第 4 步统一负责。
## 6. 阻塞矩阵与交接
| 触发条件 | 处置 |
| --- | --- |
| 需要新增本计划外的公共键/端口方法/遥测列 | **停下上报**(设计 §9 边界之外即未批准) |
| 批次 B③(清理期自抛 `TimeoutError`)在实现里无法确定性构造 | 改用假端口在 `except CancelledError` 内直接 `raise TimeoutError`(探针 D3 已证可复现);仍不可得则记入 findings 的豁免索引,不得删断言 |
| 批次 D2② 的量化断言在 CI 机器上抖动 | 只断言下界(返回时刻 > 期限 × 2),不断言上界;不得改成 `sleep` 猜测 |
| 真实 Redis 不可用(`REDIS_URL` 未配置) | 用例自动 skip;findings 必须显式记"未取得真实 Redis 证据",不得以 memory 结果冒充 |
| 发现 `ResultInvalid`/`RequestRejected` 退全款想顺手修 | **不修**(设计 §6.3 末段:未批准的另一族记账语义),登记为新 issue 交父会话 |
| 失败分支结算决定上移后发现某条既有用例变红 | 先 `systematic-debugging` 定根因;如确为语义变化(非取消路径的最终值应与 1.3.5 逐字相同)则**停下上报**——说明本项只改算得更早、不改算出什么 |
| 想把保守口径扩到未分类异常(`RuntimeError` 等) | **不扩**(人类仅批准取消路径);S8 回归用例就是这道锁,需要就另立 issue |
| issue #24 相关想法 | 一律不实现、不写进代码与文档 |
交接物:4 个提交、1 份 findings、CHANGELOG 未发布段。版本号 bump、tag、构建、上传 registry 与 wiki 同步**不在本计划内**,按 CLAUDE.md §4.4.1 另行执行。
## 7. 自审
| 检查 | 结论 |
| --- | --- |
| 路径/行号/签名是否可执行无 TBD | 是——所有接入点均现读行号(`retry.py:281/288/301/303/311/315/320/325/335/339`、`embedding.py:345/349/358/360/377/392/408/414/429`、`ocr.py:449`、`client.py:398`、`embedding.py:209`、`ocr.py:274`、`config.py:186/349/380-384`、`openai_compat.py:111-119/140`、`__init__.py:61-62`) |
| 是否复用而非重造 | 是——`settle_and_release`、`emit_terminal_once`、`claim_terminal`、`asyncio.timeout` 范式、FakeClock/BoundedSleep/ScriptedTransport、限流契约套件与真实 Redis 用例全部复用;新增仅 1 文件 + 1 异常类 + 1 配置键 |
| 是否有先失败后通过的证据点 | 是——T1 的 S3/S7、T2 的 A/B/D、T3 的 G 均先红 |
| 取消与降级铁律 | 未新增 `except Exception`;`CancelledError` 无新捕获点;期限未启用时不进任何上下文 |
| 反 gold-plating | `ResultInvalid`/`RequestRejected` 退全款、#24、`shield`、遥测列、Lua 一律不碰 |
| 残余诚实标注 | S3 是保守选择而非"已计费"的证明;**未分类异常(S8)仍按 `0` 退全款,属已知残留、本版不动**;清理期自抛 `TimeoutError` 时无终态行;跨 Python 版本仅 3.12.13 有探针实证——四条均已写进设计 §12,findings 需复述 |
@@ -0,0 +1,338 @@
---
type: plan
node_id: plan:2026-09-10-24-hedged-requests
title: "issue #24 长尾对冲请求与裸生成时间实施计划"
date: 2026-09-10
---
# issue #24 长尾对冲请求与裸生成时间实施计划
> 设计:`research-wiki/designs/2026-09-10-24-hedged-requests-design.md`,**人类于 2026-09-10 正式批准**(§9 H1–H7 及增补 H8 全数获批;H1 取方案 A 并发对冲,H3 取零新列档,H5 取 v1 只允许单路)。
> 计划审核门:Claude 自审 + 独立模型审查;plan 无人类门,审毕直接执行。
> 目标:① chat 链路可选对冲(挂起超阈值时并发向**异源**再发一次,先回者赢、输家取消);② `CallStats` 增 `hedges`/`generation_ms`/`hedge_won`,三条链路 `_attempt` 加 transport 级计时;③ 默认关闭,缺省行为逐字等于 1.3.6。
> 方案:设计 §3 方案 A——对冲轮 = 一次"超级尝试",复用完整准入(QuotaGate + BreakerGate + pacer + 冷却备忘),输家落 1.3.6 取消 S3 格结算,零新错误分类。
> 技术:Python 3.12+、asyncio 任务组编排(`asyncio.wait(FIRST_COMPLETED)` + 显式收口)、frozen dataclass、pytest + 事件驱动假 transport + 真实 loop 钟(4–10× 余量)、ruff、import-linter。
> 基线 HEAD:`166b286`(main,1.3.6 已发布);分支 `feature/24-hedged-requests`。
**范围纪律**: 不改四分类/熔断语义/429 分账/限流 Lua/`deadline.py`/`Permit` 端口;不做 embedding/OCR 对冲、分位数触发、同源对冲、per-call 对冲参数;不引入 shield/后台任务;不加遥测新列(`hedge_cancelled` 字符串 + `CallStats` 三字段经既有通道带出)。
## 1. 边界、授权与执行纪律
| 项目 | 固定边界 |
| --- | --- |
| 唯一 writer | 一工作区一 writer;父会话负责前台委派与审核派发。1.3.X 合并/发布授权沿用;跨到 1.4 或新公共面变化须停下确认 |
| 公共面 | 只做设计 §9 已批准项:`Transport.complete` 加 `first_token_event` 必填 kw(H2)、两配置键 `{SCOPE}__HEDGE__AFTER_S`/`{SCOPE}__HEDGE__MAX_EXTRA`(H4)、`GatewayClient.__init__` 两 keyword-only 参数、`CallStats` 三字段(H3+H8)、输家 `hedge_cancelled` 标签。**不新增其它键/端口方法/遥测列/异常类** |
| 对冲边界 | 仅 chat(RetryMW);EmbeddingClient/OcrClient 不加对冲参数(非目标 A),但它们的 `_attempt` 照样加 generation 计时点(H8);v1 单路对冲(H5) |
| 记账边界 | 输家取消走既有取消路径:`settlement_known=False` → `settle(est)` 保留预扣(`retry.py:327-331`);输家**不** `record_failure`、不喂熔断/健康分(挂起 ≠ 源死亡);两任务都失败才进重试且 `fails += 1` 只计一次(H6) |
| 取消铁律 | 外部取消到达时两任务同消并穿透;不 shield、不留后台任务;收口 await 允许被再取消(同 136 清理纪律) |
| 降级方向 | 限流/熔断后端不可用 → 照常冒泡(fail-closed);准入失败 → **静默放弃对冲**,原请求继续等(不抛错、不硬等);遥测仍 warning 降级 |
| 时钟纪律 | 对冲触发只用**相对时长 + 事件循环钟**(`asyncio.wait` timeout),绝不读注入 `now`;`generation_ms` 计时用该链路既有注入钟(与 `total_latency_ms` 同钟,差值才有意义;生产即 `time.monotonic`) |
| 证据纪律 | 不打印 `.env`/token/Authorization;不提交 `.pi/`、`tests/outputs/`;测试事件驱动,**禁 sleep 撞窗口**;对冲阈值用 0.05s 级真实 loop 钟,断言容差 4–10×(`tests/unit/test_streaming.py` 既有范式,不标 slow) |
Skill 纪律:T1–T3 行为变更执行 `test-driven-development`(先红后绿证据落在本会话工具输出);每次提交执行 `commit`;T4 前执行 `requesting-code-review` 与 `verification-before-completion`;异常先 `systematic-debugging`。
**保真校验**: 本计划不涉及 `reference/` 参考实现迁移(对冲编排为 D13 自研语义,蓝本即本库 1.3.6 的准入/结算/取消机制),保真校验不适用。
## 2. 文件职责与不变接缝
| 动作 | 精确路径 | 职责 |
| --- | --- | --- |
| 修改 | `src/polygateway/ports.py` | `Transport.complete`(:51-60)加 `first_token_event` 必填 kw;顶部加 `import asyncio`(stdlib,不违 P7) |
| 修改 | `src/polygateway/transports/openai_compat.py` | `complete`(:426-436)加参透传;`_complete_stream`(:552 起)首 token 处(:573-575)置位;`_complete_once`(:646 起)接收但**永不置位**(docstring 明写) |
| 修改 | `src/polygateway/middleware/retry.py` | `_attempt`(:270-353)加 `first_token_event`/`generation_sink` 私有 kw + transport 级计时;`__call__`(:224-267)单轮尝试 → 任务组编排;`_past_hedge_window`/`_attempt_hedged`/`_combine_failures` 新方法;CancelledError 分支(:327-334)`hedge_cancelled` 标签;`__init__` 加 `hedge_after_s` |
| 修改 | `src/polygateway/middleware/admission.py` | `pick`(:171-213)加 keyword-only `exclude: frozenset[str] \| None = None` |
| 修改 | `src/polygateway/types.py` | `CallStats`(:296-318)增三字段(全带默认值,追加在 `total_latency_ms` 后);`_CallContext`(:321-360)docstring 修订 + 三个计数 + `record_generation`/`register_hedge`;`snapshot`(:348-353)填三字段 |
| 修改 | `src/polygateway/config.py` | `_RESERVED_SEGMENTS`(:57)加 `"HEDGE"`;`GatewaySettings` 字段(:190 后)加 `hedge_after_s`/`hedge_max_extra`;`__post_init__`(:201 后)加 `_validate_hedge()`;新增 `_load_hedge`(:695 `_load_call_deadline` 之后)与模块级 `check_hedge_assembly` 守卫;`from_env`(:396 同列)透传 |
| 修改 | `src/polygateway/client.py` | `__init__`(:239 后)加两 keyword-only 参数,入口即校(复用 `check_hedge_assembly`);RetryMW 构造(:254-273)传 `hedge_after_s`;`from_settings`(:511 同列)透传 |
| 修改 | `src/polygateway/embedding.py` | `_attempt`(:351 起,transport 调用 :373)两侧计时 + 成功分支 `record_generation(accumulate=True)` |
| 修改 | `src/polygateway/ocr.py` | `_attempt`(:376 起,`_invoke` 调用 :397)两侧计时 + 成功分支 `record_generation(accumulate=False)` |
| 新建 | `tests/unit/test_hedge.py` | 对冲编排全部用例(批次 G) |
| 修改 | `tests/unit/test_openai_compat.py` | 批次 A(端口加参);`_complete` helper(:82-90)同步签名 |
| 修改 | `tests/unit/test_retry.py` `test_types.py` `test_embedding.py` `test_ocr_client.py` `test_client.py` `test_config.py` | 批次 B–F、H;`test_retry.py:82` FakeTransport 签名同步 |
| 修改 | `tests/unit/test_backpressure.py:213` `tests/unit/test_ports.py:75` `tests/unit/test_client.py:1741` `tests/integration/test_redis_cross_connection.py:78` `tests/e2e/conftest.py:284-303` | fake/包装 transport 签名同步(e2e 包装**转发** `first_token_event`) |
| 修改 | `tests/unit/test_live_evidence.py:270-279`(`_complete` helper)+`:1258`(直调 `ObservedTransport(...).complete(...)`);`tests/unit/test_usage_source_domain.py:135`(直调真实 `OpenAICompatTransport.complete`) | **调用方**同步(独立审 B1): helper 加 `first_token_event=None` 转发、两直调传 `None`;不传则必填 kw 报 `TypeError`,unit 门必红 |
| 修改 | `CHANGELOG.md`、`README.md`、`.env.example` | 新键、三字段、对外承诺措辞(§4 T4) |
| 新建 | `research-wiki/findings/2026-09-10-24-hedged-requests-validation.md` | 红绿、命令、豁免索引,≤300 行 |
**不改**:`errors.py`(零新异常)/`deadline.py`/`telemetry/schema.py`(零新列)/`middleware/{ratelimit,breaker,structured,cache,telemetry}.py`/`backends/**`(含全部 Lua)/`tests/contracts/**`;`embedding.py`/`ocr.py` 除计时点外一字不动;`StallClock`/`backoff_delay`/`settle_and_release` 逐字不动。若必须突破本清单,先说明最小原因交父会话核定。
## 3. 跨任务接口(可执行定义,禁止占位)
### 3.1 T1:`Transport.complete` 加首 token 事件(H2)
`ports.py:51-60` 签名改为(顺序追加在 `reasoning_effort` 后,**必填、不设默认值**,与端口既有约定同款):
```python
async def complete(
self, *, messages: list[dict[str, Any]], source: SourceConfig, stream: bool,
overlay: dict[str, Any], call_id: str, reasoning_effort: Effort | None,
first_token_event: asyncio.Event | None,
) -> TransportResult: ...
```
docstring 补两句:`None` = 调用方不观测首 token(未启用对冲);非流式实现**永不置位**(物理上无中途信号,事件自然退化为纯时间阈值)。`OpenAICompatTransport.complete`(:426-436)加同款必填 kw 并透传两条路径;`_complete_stream` 在 :573-575 `if ttft_ms is None:` 块内加:
```python
if first_token_event is not None:
first_token_event.set()
```
`_complete_once`(:646)接收该参数但永不置位,docstring 明写"非流式无中途信号"。`retry.py:291-300` 调用处 T1 先传字面 `first_token_event=None`(必填参数不传即全库 TypeError;T3 换成真事件)。
假 transport 同步纪律(`test_retry.py:71-73` 既有注释的同款):签名加 `first_token_event`,**不给默认值**;除 `test_hedge.py` 外所有 fake 忽略该参数即可。e2e 包装(`tests/e2e/conftest.py:284-303`)必须**转发**给被包 transport。
### 3.2 T2:`CallStats` 三字段与 `_CallContext` 计数(H3+H8)
`types.py` `CallStats`(:296-318)在 `total_latency_ms` 后追加:
```python
hedges: int = 0
"""本次逻辑调用实际并发发出的对冲路数(触发但准入失败静默不计);1.3.6 及以前恒 0。"""
generation_ms: int = 0
"""裸生成时间: 赢家/成功那次 transport 调用的墙钟时长(口径见设计 §4.5 H8)。"""
hedge_won: bool = False
"""赢家是否为对冲路;无对冲恒 False。"""
```
`_CallContext`(:321-360):docstring "每调用一个实例的**单任务**对象" 修订为 "每逻辑调用一个实例,**可多任务并发登记**(对冲);全部方法无 await,事件循环内任务安全";`__slots__` 与 `__init__` 加 `_generation_ms: int`/`_hedges: int`/`_hedge_won: bool`;新增两个同步方法:
```python
def record_generation(self, elapsed_ms: int, *, accumulate: bool) -> None:
"""chat/OCR 覆盖(结构化重问最后一轮为准);embedding 分批累加。"""
self._generation_ms = self._generation_ms + elapsed_ms if accumulate else elapsed_ms
def register_hedge(self, *, hedge_won: bool) -> None:
"""对冲路实际发出即计数;赢家裁定后一次性登记。"""
self._hedges += 1
self._hedge_won = hedge_won
```
`snapshot`(:348-353)按字段名填 `hedges=self._hedges, generation_ms=self._generation_ms, hedge_won=self._hedge_won`。
### 3.3 T2:三条链路 `_attempt` 的 transport 级计时点
统一形态:计时**只包 transport 调用本身**,用该链路既有注入钟 `self._now`(生产 = `time.monotonic`);起点紧贴调用前、终点在返回后首句,**中间无 await**(取消落进来时 transport 未返回,本就不计)。
| 链路 | 计时点 | 记录点 |
| --- | --- | --- |
| chat(`retry.py:291-300`) | `gen_started = self._now()` 紧贴 `await self._transport.complete(...)` 前;返回后首句 `generation_sink.append(int((self._now() - gen_started) * 1000))` | **不在 `_attempt` 内记录**——对冲赢家归属由编排裁定。`_attempt` 加私有 kw `generation_sink: list[int]`;`__call__`(:248-256)每轮建 sink,`outcome` 为 `LLMResponse` 且 `call_context` 非 None 时 `record_generation(sink[0], accumulate=False)`(与 `register_attempt` 同款 None 守卫) |
| embedding(`embedding.py:373`) | 同款两侧包 `await self._transport.embed(...)` | 成功分支(`:386 settlement_known = True` 之后)`context.record_generation(gen_ms, accumulate=True)`——分批累加 |
| ocr(`ocr.py:397`) | 同款两侧包 `await self._invoke(...)` | 成功分支 `context.record_generation(gen_ms, accumulate=False)` |
缓存命中/空输入不产生 transport 调用 → `generation_ms` 恒 0(0 是实测,不违 `types.py` "None 表未知" 惯例——本字段语义是时长不是用量)。
### 3.4 T3:对冲编排(retry.py,设计 §4.6 的唯一实现形态)
`RetryMW.__init__` 加 keyword-only `hedge_after_s: float | None = None`(存 `self._hedge_after_s`;`hedge_max_extra` **不下传**——v1 编排固定单路,H5;配置面值域与 v1 生效口径由 §3.6 守卫负责)。模块级:
```python
_HEDGE_LOSER_ATTR = "_polygateway_hedge_loser"
"""编排在 cancel() 之前给输家任务置位的标记;_attempt 读它选遥测标签。"""
```
`__call__`(:244-256)循环体内:`self._hedge_after_s is None` → **逐字旧路径**(`_attempt` 传 `first_token_event=None`,单任务,默认关闭回归门据此成立);否则 `outcome = await self._attempt_hedged(request, picked, reasons, attempt_fails)`,其后的 `_is_rate_limited`/refund/`fails` 计数机制一字不动。
`_attempt_hedged` 编排(Phase 注释组织;`_attempt` 相应加 `first_token_event` kw 取代 T1 的字面 None):
```python
# Phase 1 启动原路: 事件与 sink 每轮新建(局部状态,严禁实例属性)
source, permit, entry = picked
first_token: asyncio.Event = asyncio.Event()
sink_p: list[int] = []
primary = asyncio.create_task(
self._attempt(request, source, permit, entry, reasons, attempt_fails,
first_token_event=first_token, generation_sink=sink_p)
)
# Phase 2 触发窗: 只认"阈值到 + 首 token 未至 + 原路在途"(loop 相对时长,不读注入 now)
if not await self._past_hedge_window(primary, first_token):
return await primary # 原路已了结/首 token 已至: 等价于未配置对冲
# Phase 3 异源准入(完整 pick 路径, 无旁路): 拿不到候选 = 静默等原路
hedge_picked, _ = await self._admission.pick(
reasons, attempt_fails, exclude=frozenset({source.name})
)
if hedge_picked is None:
return await primary
```
Phase 4-5(赢家裁定与收口)规则:
```python
sink_h: list[int] = []
hedge = asyncio.create_task(
self._attempt(request, *hedge_picked, reasons, attempt_fails,
first_token_event=None, generation_sink=sink_h) # v1 单路: 对冲路不再触发梯次
)
done, pending = await asyncio.wait({primary, hedge}, return_when=asyncio.FIRST_COMPLETED)
```
- **裁定**:done 中有成功(LLMResponse)即赢家;两路同时成功(竞速)→ **原路优先**(`hedge_won=False`,保守不弃原路成果);done 全是失败且 pending 非空 → 等 pending 了结后再裁定。
- **收口**:赢家产生后,对 pending 中的输家先 `setattr(task, _HEDGE_LOSER_ATTR, True)` 再 `task.cancel()`,然后 `await asyncio.gather(*pending, return_exceptions=True)`——输家 finally 的结算/遥测跑完才返回(快照含输家,`attempts==2`;`client.py:442` 快照在返回后,天然在收口之后)。两路同时完成的竞速落选者**不置标记**(它没被取消,attempt 行是正常成功/失败行)。
- **登记**:对冲路实际发出(Phase 3 之后)即计一次;赢家裁定后 `context.record_generation(赢家 sink[0], accumulate=False)` + `context.register_hedge(hedge_won=winner is hedge)`;**两败轮次(无赢家)同样照登** `register_hedge(hedge_won=False)`——设计 §4.5 已批准口径是"实际并发发出即计,触发但准入失败静默不计";`call_context is None` 时跳过(同 `register_attempt` 守卫)。
- **两败汇合**(H6,只计一次预算;deterministic):
```python
def _combine_failures(primary_f: _Failed, hedge_f: _Failed) -> _Failed:
"""任一非 429 优先(计预算);两路皆 429 才按 429 免预算退还 stall 账;同类取原路。"""
if _failure_reason(primary_f.exc) == "rate_limited" != _failure_reason(hedge_f.exc):
return hedge_f
return primary_f
```
- **取消穿透**:Phase 2-5 全程包 `except BaseException`(含 `CancelledError` 与准入冒泡的 `GovernanceBackendError`)→ 两任务(存在者)`cancel()` + `gather(return_exceptions=True)` 尽力收口后 `raise` 原异常;收口 await 允许被再取消,不 shield。
`_past_hedge_window` 实现红线(waiter 任务必须收口,且**不得吞外部取消**):
```python
async def _past_hedge_window(self, primary: asyncio.Task, first_token: asyncio.Event) -> bool:
waiter = asyncio.create_task(first_token.wait())
try:
await asyncio.wait({primary, waiter}, timeout=self._hedge_after_s,
return_when=asyncio.FIRST_COMPLETED)
finally:
waiter.cancel()
try:
await waiter
except asyncio.CancelledError:
if asyncio.current_task().cancelling(): # 外部取消,穿透
raise
return not primary.done() and not first_token.is_set()
```
输家标签:`_attempt` 的 CancelledError 分支(:327-334)把 `error="cancelled"` 换成按标记选择——`label = "hedge_cancelled" if getattr(asyncio.current_task(), _HEDGE_LOSER_ATTR, False) else "cancelled"`。竞速误贴(外部取消与对冲取消同时到达)记账方向一致(est 保留),属设计 §4.5 已批准的可接受残留。
### 3.5 T3:`admission.pick` 私有排除参数(设计 §4.2)
`pick`(:171-173)签名加 keyword-only `exclude: frozenset[str] | None = None`;候选循环**首部**加:
```python
if exclude and cand.name in exclude:
continue # 被排除不是源的拒绝: 不计 gate_rejections、不写 reasons
```
理由:写进 `reasons`/`gate_rejections` 会污染 `on_no_runnable`(:224)的分派判据与 `per_source_reasons` 对账。对冲调用方拿到 `None` 的处置是静默等原路(§3.4 Phase 3),**严禁**对它调 `on_no_runnable`(那会按 quota/circuit 策略抛错或睡觉,语义全错)。三条既有调用方(`retry.py:244`、`embedding.py:332`、`ocr.py:348` 所在循环)不传该参数,行为逐字不变。
### 3.6 T3:配置两键 + 两守卫 + client 透传(H4)
`config.py` 改动(单一定义点纪律,值域/交叉守卫只写一份):
| 项 | 精确定义 |
| --- | --- |
| 保留段 | :57 `_RESERVED_SEGMENTS` 加 `"HEDGE"`(防 provider 段撞名);两键均 3 段,`:405` 的 `len(parts) != 4` 判据天然跳过 `_load_sources` |
| loader | 新增 `_load_hedge(scope, env)`(:695 `_load_call_deadline` 之后):`AFTER_S` 用 `_first` + `_cast(..., "float", ...)` + `ensure_call_deadline`(origin 传实际命中键名,同 `_load_call_deadline` 纪律);`MAX_EXTRA` 用 `_first` + `_cast(..., "int", ...)`,未设 = 1;返回 `{"hedge_after_s": ..., "hedge_max_extra": ...}` |
| 字段 | `GatewaySettings` :190 后追加 `hedge_after_s: float \| None = None`、`hedge_max_extra: int = 1` |
| 守卫 | 新增模块级 `check_hedge_assembly(*, hedge_after_s, hedge_max_extra, sources, call_deadline_s, origin)`,返回归一化后的 `hedge_after_s`;`GatewaySettings.__post_init__`(:201 后)加 `_validate_hedge()` 调它并 `object.__setattr__` 写回归一化值(同 `_validate_call_deadline` 形态);`GatewayClient.__init__` 调同一份(client.py:22 已 import config,合法) |
| from_env | :396 同列加 `**_load_hedge(scope_u, env)` |
`check_hedge_assembly` 守卫全表(设计 §5;`hedge_after_s is None` 时值域归一化后直接返回,交叉守卫不查):
| 守卫 | 判定 |
| --- | --- |
| 值域 | `ensure_call_deadline(hedge_after_s, origin)`(None 或有限正数,复用 `deadline.py` 同款校验) |
| `hedge_after_s ≥ min(源 timeout_s)` | `ValueError`(对冲永不可能触发,配置即错误) |
| `hedge_after_s ≥ call_deadline_s`(两者皆设) | `ValueError`(期限先于对冲触发) |
| `hedge_after_s ≥ min(已设 ttft_timeout_s)` | 装配期 **warning**(流式档被 TTFT 看门狗先行切断;非流式仍有效,不升 ValueError) |
| 单源 scope 设了阈值 | 装配期 **warning**,允许(运行期拿不到候选自然静默) |
| `hedge_max_extra` | 非 int/bool 或不在 [1,3] → `ValueError`;**>1 → warning**"v1 仅单路对冲生效,梯次追加为 H5 预留"(值域按设计 §5 表放到 3,运行期 H5 只允许 1,warning 保 fail-loud 不静默) |
`client.py`:`__init__` :239 后加 keyword-only `hedge_after_s: float | None = None, hedge_max_extra: int = 1`,在 :245-247 期限校验同列调 `check_hedge_assembly(hedge_after_s=..., hedge_max_extra=..., sources=sources, call_deadline_s=self._call_deadline_s, origin="GatewayClient(...)")`;RetryMW 构造(:254-273)传 `hedge_after_s=` 归一化值(**max_extra 不下传**,§3.4);`from_settings` :511 同列传 `settings.hedge_after_s`/`settings.hedge_max_extra`。`EmbeddingClient`/`OcrClient` 不加对冲参数;它们的 settings 嵌 `GatewaySettings` 故守卫照常跑(文档明写对冲键只对 chat 生效)。
## 4. 任务与提交点(4 个原子提交)
### T0:设计增补并入与本计划(本任务,无代码)
产出:设计文档 §4.5/§7/§8/§9 增补(已完成)+ 本计划。不提交代码。
### T1 → 提交 1 `feat: add a required first-token event to the transport port`
1. **先红**:批次 A(`tests/unit/test_openai_compat.py` 四用例),确认失败为 `TypeError`(签名无此 kw)而非断言值不符。
2. 按 §3.1 改 `ports.py`、`openai_compat.py`、`retry.py:291-300` 传 None;同步六处 fake/包装(§2 表)+ `test_live_evidence.py`/`test_usage_source_domain.py` 三处调用点(§2 表末行);`_complete` helper(:82-90)加 `first_token_event=None` 默认转发(测试设施,与生产端口的"必填无默认"约定不冲突——生产端口不变)。
3. **后绿**:批次 A 通过;`pytest tests/unit -q` 全绿且不改一行既有断言。
4. 暂存:`src/polygateway/ports.py`、`src/polygateway/transports/openai_compat.py`、`src/polygateway/middleware/retry.py`、六个测试文件。
### T2 → 提交 2 `feat: expose bare generation time and hedge flags in CallStats`
1. **先红**:批次 B(`test_types.py` 三用例,字段不存在 → `TypeError`/`AttributeError`)+ C-F(各链路计时断言,字段恒 0 → 断言失败)。
2. 按 §3.2 改 `types.py`;按 §3.3 改三条 `_attempt` 与 `retry.py::__call__` sink 接线;**对冲计数本提交保持 0/False**(T3 才登记)。
3. **后绿**:批次 B–F 通过;`pytest tests/unit tests/contracts -q` 全绿不改既有断言。
4. 暂存:`src/polygateway/types.py`、`middleware/retry.py`、`embedding.py`、`ocr.py`、五个测试文件。
### T3 → 提交 3 `feat: add opt-in cross-source hedged requests for chat`
1. **先红**:批次 G(`test_hedge.py`,对冲未实现 → 挂起用例超时或 `hedges==0` 断言失败)+ H(`test_config.py`,键未识 → `ValueError`/`None` 断言失败)。
2. 按 §3.5 改 `admission.py` → §3.6 改 `config.py`/`client.py` → §3.4 改 `retry.py` 编排。
3. **后绿**:批次 G/H 通过;**默认关闭回归门**:`pytest tests/unit tests/contracts -q` 全绿且不改一行既有断言(批次 I);`make lint` 通过。
4. 暂存:`src/polygateway/middleware/{retry,admission}.py`、`src/polygateway/{config,client}.py`、`tests/unit/test_hedge.py`、`tests/unit/test_config.py`。
### T4 → 提交 4 `docs: document hedged requests and bare generation time`
1. `CHANGELOG.md` 未发布段:对冲三句强制措辞——**默认关闭,开启即用配额换延迟**(挂起窗口内 in-flight 翻倍);**输家可能已被上游计费**(est 保留只是闸内保守记账,非上游计量);**对冲只对 chat 生效,embedding/OCR 仅获得 `generation_ms` 计时**。另记 `CallStats` 三字段口径(`generation_ms` 与 `total_latency_ms` 差值 = 波动开销)与 `hedge_cancelled` 标签的遥测 join 用法。
2. `README.md`:能力表加"长尾对冲(可选)"一行(三句措辞同上);配置键清单加两键与值域/守卫;`CallStats` 说明处加三字段。
3. `.env.example`:`LLM__CALL_DEADLINE_S` 注释行后加 `# LLM__HEDGE__AFTER_S=` 与 `# LLM__HEDGE__MAX_EXTRA=1`(缺省关闭,说明触发语义与成本含义)。
4. `research-wiki/findings/2026-09-10-24-hedged-requests-validation.md`:红绿证据、命令与退出码、豁免索引(含 H5 的 v1 单路口径与竞速误贴残留)。
5. wiki 注册本计划与 findings(add_entity/add_edge/rebuild_index);独立验证(全新上下文 verifier)与整分支审查在本提交前完成;版本 bump/发布**不在本计划内**。
## 5. 测试矩阵 → 任务映射
**设施复用核对(动手前必读)**:`tests/unit/test_retry.py:120-127` 的 `FakeSleep` 只记录不推进时钟——退避推进须用例自带 `async def sleep(s): clock.advance(s)` 闭包;`tests/unit/test_embedding.py:217` 已有 `_ClockAdvancingEmbedTransport`(尝试内推进时钟),批次 D 直接复用;`tests/unit/test_config.py:31-33` 已有 loguru WARNING 捕获 fixture,warning 断言用它(caplog 抓不到 loguru);`tests/unit/test_client.py:134-139` 已有 `InMemoryCache` 命中回路,批次 F 复用;对冲编排用例(`test_hedge.py`)用**真实 loop 钟**(不注入 FakeClock),阈值 0.05s、断言容差 4–10×;取消窗口用 `entered` Event 范式(`test_retry.py:79-89` 既有),禁 sleep 撞窗口。
| 批次 | 断言(→ 任务) | 落点(精确测试名) |
| --- | --- | --- |
| A 端口加参 | 流式首 token 置位事件;非流式永不置位;`None` 不观测行为不变;漏传 → `TypeError`(钉住必填)(→T1) | `test_openai_compat.py::test_stream_sets_first_token_event`、`test_non_stream_never_sets_first_token_event`、`test_none_first_token_event_keeps_behavior`、`test_first_token_event_is_required_keyword` |
| B 三字段 | 三字段默认值(0/0/False);仅旧三参数构造 `CallStats(...)` 不炸;`record_generation` 覆盖/累加语义;`register_hedge` 计数;`snapshot` 带出三字段(→T2) | `test_types.py::test_callstats_hedge_fields_default`、`test_callcontext_record_generation_overwrite_and_accumulate`、`test_callcontext_register_hedge_counts`、`test_snapshot_includes_hedge_fields` |
| C chat 计时 | 脚本 [Transient, ok]:退避推进时钟 5s、成功次 transport 推进 0.2s → `generation_ms == 200` 且 `total_latency_ms ≥ 5200`(证明排除 backoff);无对冲时 `hedges == 0`/`hedge_won is False`(→T2) | `test_retry.py::test_generation_ms_excludes_backoff_and_admission`、`test_generation_ms_zero_hedge_flags_without_hedging`(配 `_GenClockTransport` 薄包装:委托 FakeTransport 并在返回前 `clock.advance(delta)`) |
| C2 重问覆盖 | 结构化首轮坏 JSON(transport 推进 1s)、重问轮好 JSON(推进 0.2s)→ `generation_ms == 200`(最后一轮覆盖,非累加)(→T2) | `test_client.py::test_generation_ms_structured_last_round_wins`(复用 `_client(structured_strategy=...)` 与 :129-132 范式;若 `_client` 未暴露 `now` 注入,按其既有模式补 keyword 参数——测试设施非公共面) |
| D embedding 计时 | 两批各推进 0.3s → `generation_ms == 600`(批次和)(→T2) | `test_embedding.py::test_generation_ms_sums_batch_transports`(复用 `_ClockAdvancingEmbedTransport`) |
| E ocr 计时 | 单次 transport 推进 0.4s → `generation_ms == 400`(→T2) | `test_ocr_client.py::test_generation_ms_single_transport_call`(同款薄包装,推进 `test_ocr_client.py:344` 那份本地 FakeClock——历史坑:不是 contracts 那份) |
| F 缓存命中 | 第二次同 key 调用 `generation_ms == 0` 且 `attempts == 0` 且 `cache_hit is True`(→T2) | `test_client.py::test_cache_hit_generation_ms_zero`(复用 :134-139 回路) |
| G 对冲编排 | 见下表(→T3) | `tests/unit/test_hedge.py`(新建) |
| H 配置守卫 | 两键 env 解析(3 段键不被当源字段、`HEDGE` 在保留段);非法值四路(env/直接构造/`dataclasses.replace`/client 直传)→ `ValueError`;`after_s ≥ min(timeout_s)` → ValueError;`after_s ≥ min(ttft)` → warning;单源 → warning;`after_s ≥ call_deadline_s` → ValueError;`max_extra` 0/"x" → ValueError、2/3 → warning 且生效 1(→T3) | `test_config.py::test_hedge_keys_from_env_skip_source_loader`、`test_hedge_after_s_domain_four_paths`、`test_hedge_guard_below_min_timeout_raises`、`test_hedge_guard_ttft_warns`、`test_hedge_guard_single_source_warns`、`test_hedge_guard_deadline_conflict_raises`、`test_hedge_max_extra_v1_cap`;client 直传入口校验 `test_client.py::test_client_hedge_params_entry_validation` |
| I 默认关闭回归 | 不配对冲键时 `tests/unit` + `tests/contracts` 全绿,**不改一行既有断言**(→T3 门) | 全套件 |
批次 G(`test_hedge.py`)用例全表——设施:两源 scope(`s1`/`s2`),event-driven 假 transport(每源一对 `entered`/`release` Event + 可脚本化"先置 first_token 再挂起"),真实 loop 钟,`hedge_after_s=0.05`:
| 测试名 | 断言(设计 §8 矩阵编号) |
| --- | --- |
| `test_non_stream_triggers_hedge_and_fast_leg_wins` | s1 挂起、s2 即时成功:总时长 < 10× 阈值(①);`hedges==1`、`hedge_won is True`;`generation_ms < total_latency_ms` 且 ≥ s2 实际 transport 耗时下界(⑩:不含触发前等待) |
| `test_stream_triggers_only_when_first_token_absent` | 两例:首 token 未至 → 触发;假 transport 先 `first_token_event.set()` 再挂起 → **不触发**,`attempts==1`(①) |
| `test_hedge_goes_to_other_source` | 对冲请求落在 s2(transport.calls 断言);`logical_call_id` 两行一致(②⑤) |
| `test_hedge_silent_when_no_candidate` | s2 permit 预占满 → 不对冲:`hedges==0`、`attempts==1`、原请求放行后正常成功(③) |
| `test_hedge_silent_when_single_source` | 单源 scope:运行期自然静默,行为与不配阈值逐字相同(②) |
| `test_winner_settles_actual_loser_keeps_est` | memory limiter:赢家源 `tpm_used == 真实 usage`,输家源 `tpm_used == est`(S3 格);调用结束后两源 `inflight == 0`(④) |
| `test_loser_row_labelled_hedge_cancelled` | 假 emitter:输家 attempt 行 `error=="hedge_cancelled"`,赢家行无 error;两行 `logical_call_id` 相同;无 `terminal_failure` 行(④⑤) |
| `test_loser_does_not_feed_breaker` | memory gate:挂起源 `failure_count` 不变、健康喂数无 `ok=False`;赢家照常 `record_success`(④,§3 关键判断) |
| `test_attempts_two_and_no_task_leak` | `call_stats.attempts == 2`;返回后 `asyncio.all_tasks()` 无本调用残留任务(⑤) |
| `test_external_cancel_cancels_both_legs` | 两路均挂起,`entered` 双置位后 `task.cancel()`:`CancelledError` 上抛;两行 attempt 均 `"cancelled"`(标记只在赢家产生后置,外部取消无 `hedge_cancelled`);两 permit 释放(⑦) |
| `test_deadline_cuts_hedged_tree` | client 级 `call_deadline_s=0.2` + 两路挂起 → `CallDeadlineExceeded`;两 permit 释放(⑧) |
| `test_primary_late_success_wins_back` | s1 挂 0.3s 后成功、s2 对冲路挂起:对冲已触发但原路先完成 → `hedge_won is False`、`generation_ms` 为原路时长、s2 行 `hedge_cancelled`(⑩ 取快者) |
| `test_both_fail_counts_budget_once` | 两路 Transient:`max_attempts=2` 时恰进第二轮(两败只计一次);最终 `retry_exhausted` 在第二轮两败后(H6) |
| `test_both_429_refund_no_budget` | 两路 429:不耗预算(`max_attempts=1` 不抛 `retry_exhausted`),stall 账退还——小 `stall_window_s` 下终局 `reason=="stalled"` 而非 `"retry_exhausted"` |
| `test_mixed_429_and_failure_counts_budget` | 一路 429 一路 Transient → 计一次预算、不退还 stall 账(§3.4 `_combine_failures`) |
命令(全部 `conda run -n PolyGateway`,禁接管道):`pytest tests/unit/test_openai_compat.py -q`、`pytest tests/unit -q`、`pytest tests/unit/test_hedge.py -q`、`pytest tests/unit tests/contracts -q`、`make lint`。真实 Redis/网关 slow 用例本计划不新增、不跑,由发布清单第 4 步按 diff 交集选子集(本 diff 触及 retry/限流结算路径,Redis 时间语义变体届时在交集内)。
## 6. 阻塞矩阵与交接
| 触发条件 | 处置 |
| --- | --- |
| 需要新增本计划外的公共键/端口方法/遥测列/异常类 | **停下上报**(设计 §9 边界之外即未批准) |
| `asyncio.current_task().cancelling()` 在目标 Python 版本语义不符 | 3.12 语义同 136 探针已验证的取消计数;若实测不符,改用"取消标志位置于 `_attempt_hedged` 局部"方案并记入 findings,不得吞取消 |
| 两路同时成功的竞速在测试中无法确定性构造 | 用双 Event 栅栏(两 transport 都等同一放行事件)构造;仍不可得则记入 findings 豁免索引,不得删"原路优先"断言 |
| 批次 G 计时断言在 CI 机器抖动 | 只断言下界与相对比较(`generation_ms < total_latency_ms`、总时长 < 10× 阈值),不断言精确值;精确值断言只在注入钟批次(C–F) |
| 想顺手让对冲路再触发梯次对冲 | **不做**(H5:v1 单路;`hedge` 任务恒传 `first_token_event=None`) |
| 想把输家记进熔断/健康分 | **不记**(§3 关键判断:挂起 ≠ 源死亡);运维面靠 `hedge_cancelled` 遥测行统计 |
| `hedge_max_extra > 1` 应 warning 还是 ValueError 存疑 | 本计划取 warning(§5 值域 [1,3] 与 H5 "v1 只允许 1" 的并存解);若人类审定应 ValueError,改 `check_hedge_assembly` 一处 + 批次 H 一条断言 |
| 发现 embedding/OCR 也想加对冲参数 | **不加**(非目标 A,H7);登记为后续 issue |
交接物:4 个提交、1 份 findings、CHANGELOG 未发布段。版本号 bump、tag、构建、上传 registry 与 wiki 同步**不在本计划内**,按 CLAUDE.md §4.4.1 另行执行。
## 7. 自审
| 检查 | 结论 |
| --- | --- |
| 路径/行号/签名是否可执行无 TBD | 是——接入点均现读:`ports.py:51-60`、`openai_compat.py:426/552/573-575/646`、`retry.py:244/248-256/270/291-300/327-334`、`admission.py:171-213/224`、`types.py:296-318/321-360`、`config.py:57/190/201/396/405/695`、`client.py:239/245-247/254-273/511`、`embedding.py:373/386`、`ocr.py:397`、六个 fake transport 精确行号 |
| 是否复用而非重造 | 是——取消结算 S3 格、准入全链路、`settle_and_release`、`asyncio.wait` 范式、`ensure_call_deadline` 值域校验、`_ClockAdvancingEmbedTransport`/loguru 捕获 fixture/InMemoryCache 回路全部复用;新增仅 1 测试文件 + 2 配置键 + 3 字段 |
| 先失败后通过证据点 | 是——T1 批次 A(TypeError)、T2 批次 B–F(字段缺失/恒 0)、T3 批次 G/H(未实现/键未识)均先红 |
| 取消与降级铁律 | 取消穿透路径显式收口不吞没;准入失败静默(设计 §4.3 批准);后端不可用照常冒泡;无 shield/后台任务 |
| 反 gold-plating | 四分类/熔断语义/429 分账/Lua/deadline.py/遥测列/embedding-OCR 对冲/分位数/同源/per-call 参数一律不碰;`hedge_max_extra` 不下传 RetryMW(v1 无消费者) |
| 跨任务签名一致 | `first_token_event`(T1 端口 → T3 接线)、`generation_sink`/`record_generation`(T2 定义,T3 编排消费)、`check_hedge_assembly`(config 定义,client 消费)三处接缝均在 §3 写出实际代码 |
| 残余诚实标注 | 输家 est 保留 ≠ 上游真实计费计量;非流式误对冲慢生物理不可分;竞速误贴标签可接受(设计 §4.5);`hedge_max_extra` v1 生效口径取 warning(§6 阻塞矩阵已列复核点) |
@@ -0,0 +1,463 @@
---
type: plan
node_id: plan:2026-09-11-streaming-stall
title: "1.3.8 流式中段沉默:计量口径与对冲实施计划"
date: 2026-09-11
---
# 1.3.8 流式中段沉默:计量口径与对冲实施计划
> 设计:`research-wiki/designs/2026-09-11-streaming-stall-design.md`,**人类已批准 + 独立审查补遗 B1-B3/N1-N9 全处置并入,计划期补遗 B4(PM 2026-09-11 裁决)已并入 §6/§11**;本计划一切语义以该设计为准,不另行发明。
> 计划审核门:Claude 自审 + 独立模型审查;plan 无人类门,审毕直接执行。
> 目标:① P1(issue #27)`generation_ms` 口径重定义为**净生成时间**(墙钟 − Σ 超阈值中段沉默),`CallStats` 增 `stall_ms`/`stall_count` 归因字段,遥测表加两列;② P2(issue #28)`{SCOPE}__HEDGE__STALL_AFTER_S` 中段沉默对冲(独立生效,默认关闭);③ 缺省行为(两键均不设)逐字等于 1.3.7。
> 方案:P1 = 设计 §4.2 方案 1B'(净口径直接写进 `generation_ms`);P2 = 设计 §5.2 方案 2B(增量 chunk Event 等待超时触发,复用 1.3.7 对冲编排/记账/遥测全部语义)。
> 技术:Python 3.12+、asyncio 任务组编排(1.3.7 既有 `_attempt_hedged` 骨架扩两阶段触发窗)、frozen dataclass、pytest + 事件驱动假 transport + FakeClock/FakeSleep(净口径断言)与真实 loop 钟(对冲触发,4–10× 余量)、ruff、import-linter。
> 基线: main `a28d944`(1.3.7 已发布)+ 设计提交 `97de410`;分支 `feature/1.3.8-streaming-stall`。
**范围纪律**:不改四分类/熔断语义/429 分账/限流 Lua/`deadline.py`/`Permit` 端口/看门狗 `streaming.py` 语义;不做流式透传、内容拼接、自适应阈值、per-call 沉默阈值覆盖、embedding/OCR 对冲(设计 §8 非目标 A–F);不加墙钟/净双口径并存字段(`active_generation_ms` 否决);`generation_ms` **不进**遥测列(YAGNI,墙钟 = `generation_ms + stall_ms` 还原);除设计 §7 已批准的三次端口变更外不动任何端口签名。
## 1. 边界、授权与执行纪律
| 项目 | 固定边界 |
| --- | --- |
| 唯一 writer | 一工作区一 writer;父会话负责前台委派与审核派发。1.3.8 合并/发布授权沿用 1.3.X 惯例;跨到 1.4 或设计 §9 之外的新公共面须停下确认 |
| 公共面 | 只做设计 §7/§9 已批准项:`Transport.complete` 加 `content_event` 必填 kw(第二次端口变更)、`TransportResult` 增 `stall_ms`/`stall_count`、`TelemetryRecorder.record_llm_call` 加两参(第三次端口变更)、`CallStats` 增 `stall_ms`/`stall_count`、两配置键 `{SCOPE}__STREAM__STALL_THRESHOLD_MS`(默认 1000,可调)/`{SCOPE}__HEDGE__STALL_AFTER_S`(未设=关闭)、`GatewayClient.__init__` 对应 keyword-only 参数、遥测表 `stall_ms`/`stall_count` 两列(可空)。`generation_ms` 语义重定义为净口径(已批准例外,披露义务见 T5)。**不新增其它键/端口方法/遥测列/异常类** |
| 三次端口变更 | ①`Transport.complete` 加 `content_event`(必填、无默认,与 `first_token_event` 同款约定);②`TransportResult` 增两字段(带默认 None);③`record_llm_call` 加 `stall_ms`/`stall_count`(无默认,装配闸判据即本签名)。全部同步点见 §2.2/§2.3 同步清单(真实 grep 结果,非凭记忆) |
| 口径钉死(N8) | TransportResult `stall_ms: float \| None`;retry 层 `wall_ms=int((now−gen_started)*1000)` 照旧,`net = max(0, wall_ms − int(stall_ms or 0))`(clamp 防御);`CallStats.stall_ms = int(stall_ms)`(None 透传)——**取整换算全库只此一处**(retry 层),各实现不得另取整 |
| 钟同源纪律(N4) | 沉默序列(transport 内 `time.monotonic`)与墙钟(retry 注入 `now`,生产即 `time.monotonic`)生产同钟;**FakeClock 测试用自控 stall 值的假 transport,真实 transport 测试不注入假钟**;对冲触发只用事件循环相对时长(`asyncio.wait` timeout),绝不读注入 `now`(1.3.7 §4.1 同款) |
| 取消铁律 | 外部取消到达时对冲两路同消并穿透;不 shield、不留后台任务;`_past_hedge_window` 两阶段重构**逐字保留** waiter 收口 + `cancelling()` 穿透纪律(retry.py:556-568 既有范式);stall 等待每轮迭代自建 waiter |
| Event 并发纪律(N3) | ①超时到期后触发对冲前必须复检 `is_set()`;②check→clear 之间不得有 await;③对冲路 `content_event` 恒 None(1.3.7 首 token 约定延伸,v1 不梯次);④等待者唯一(编排),Event 无需多等待者语义 |
| 排除条款 | 非流式 / 增量 chunk <2 → `stall_ms`/`stall_count` 为 None 且 `generation_ms` 不退减(= 原墙钟);chunk = 任何增量产出(`content` 或 `reasoning_content` 均计),空 chunk/usage 帧/SSE keepalive 不计时;对冲输家的沉默不进赢家快照(已完成落选者的沉默在其 attempt 遥测行;被取消输家无 TransportResult、`hedge_cancelled` 行 stall 恒 None) |
| 降级方向 | 准入失败 → 静默放弃对冲(不抛错不硬等);限流/熔断后端不可用照常冒泡(fail-closed);遥测写失败 warning 降级;遥测两列对既有实例**幂等** ALTER(auto_migrate 先探测后补列,manual 档裁剪+点名告警) |
| 证据纪律 | 不打印 `.env`/token/Authorization;不提交 `.pi/`、`tests/outputs/`;测试事件驱动,禁 sleep 撞窗口;红绿证据落 `tests/outputs/138/t<N>/`(pytest 输出文件,不提交) |
Skill 纪律:T1–T4 行为变更执行 `test-driven-development`(先红后绿证据落本会话工具输出 + `tests/outputs/138/t<N>/` 文件);每次提交执行 `commit`;T5 前执行 `requesting-code-review` 与 `verification-before-completion`(全新上下文 verifier);异常先 `systematic-debugging`。
**保真校验**:本计划不涉及 `reference/` 迁移(沉默计量与中段对冲为库内自研口径,蓝本即本库 1.3.7 编排与 `streaming.py` 看门狗),保真校验不适用。
## 2. 文件职责、同步清单与不变接缝
### 2.1 文件职责
| 动作 | 精确路径 | 职责 |
| --- | --- | --- |
| 修改 | `src/polygateway/ports.py` | `Transport.complete`(:56-66)加 `content_event` 必填 kw + docstring;`TelemetryRecorder.record_llm_call`(:306-344)加 `stall_ms`/`stall_count` 两参 + docstring("36 字段"→"38 字段");`TransportResult` 在 types.py(见下) |
| 修改 | `src/polygateway/types.py` | `TransportResult`(:560-586)增 `stall_ms: float \| None = None`/`stall_count: float \| None = None`(追加在 `applied_effort` 后);`CallStats`(:296-324)在 `hedge_won` 后增 `stall_ms: int \| None = None`/`stall_count: int \| None = None`;`_CallContext`(:328-388)`__slots__`/`__init__` 增两私有位、`record_generation` 增 keyword-only `stall_ms`/`stall_count`(默认 None)、`snapshot` 填两字段;`SourceConfig`(:590-700)在 `effort_fallback` 后追加 `stall_threshold_ms: int = 1000` + `__post_init__` 值域校验(正整数、拒 bool) |
| 修改 | `src/polygateway/transports/openai_compat.py` | `complete`(:427-473)加 `content_event` 透传两路径;`_complete_stream`(:553-616)chunk 循环(:580-590 邻域)记增量 chunk 时间戳 + 每 chunk `set` content_event(None 跳过),装配处(:597-616 邻域)由序列算 stall 两值;模块级纯函数 `_stall_from_chunk_times`;`_complete_once`(:660-705)接收 content_event 但**永不置位**(docstring 明写),stall 两值恒 None(默认) |
| 修改 | `src/polygateway/middleware/retry.py` | `__call__` 入口门(:273-283)改两阈值判定;`_attempt`(:304-400)加 `content_event` 私有 kw、sink 元素改三元组、成功分支取整换算 + `_emit` 传 stall;`_attempt_hedged`(:415-531)Phase 1/4 接线 content_event、Phase 5 汇合复用;`_past_hedge_window`(:533-562)两阶段重构;`_record_generation`/`_register_hedge` 邻域(净口径计算收敛一处);`__init__` 加 `stall_after_s` kw |
| 修改 | `src/polygateway/config.py` | `_RESERVED_SEGMENTS`(:57)加 `"STREAM"`;`GatewaySettings`(:192-196 后)加 `stall_threshold_ms: int = 1000`、`hedge_stall_after_s: float \| None = None`;`_validate_hedge`(:382-392)扩传新参;`from_env`(:398-427)`**_load_stream(...)`;新增 `_load_stream`(:745 `_load_hedge` 邻域);`check_hedge_assembly`(:772-849)加 `stall_after_s` 参与三守卫;`EmbeddingSettings.__post_init__`(:865-869 邻域)与 `OcrSettings`(新增 `__post_init__`)加 B4 对冲键 warning |
| 修改 | `src/polygateway/client.py` | `__init__`(:240 邻域)加 `hedge_stall_after_s`/`stall_threshold_ms` keyword-only 参数,守卫(:252-257)扩传;`stall_threshold_ms` 经 `dataclasses.replace` 分发进各 SourceConfig;RetryMW 构造(:264-283)传 `stall_after_s`;`from_settings`(:485-526)透传 |
| 修改 | `src/polygateway/middleware/telemetry.py` | `_AttemptUsage`(:199-236)增两字段;`emit_attempt`(:396-447)加 `stall_ms`/`stall_count` kw(默认 None)+ 转发;`emit_cache_hit`/`emit_terminal_failure`(:449-540)显式传 None;`_record`(:560-665)加两参并在 :618 调用点透传 |
| 修改 | `src/polygateway/telemetry/schema.py` | 五编辑点:`SQLITE_DDL`/`PG_DDL` 末尾(`total_latency_ms` 后)加两列;`SQLITE_BACKFILL`(:113-141)追加两项;`_PG_BACKFILL_DECLS`(:144-166)追加两项(保序对齐);`COLUMNS`(:177-215)追加两名;`telemetry_schema_sql`(:323-372)由常量派生**同源再生成**(本函数零编辑,列数断言随动);模块头 "36/37" 计数 docstring 同步 |
| 修改 | `src/polygateway/telemetry/sqlite.py` / `postgres.py` | `record_llm_call`(:145/:411)为 `**fields` 形态,**零签名改动**自动拾取新列;docstring "36 字段冻结签名"→"38 字段";补列/裁剪逻辑由 schema 常量驱动,零代码改动 |
| 修改 | `tests/unit/test_openai_compat.py` | 批次 A(content_event 四用例 + stall 纯函数用例);`_complete` helper(:82-93)同步签名 |
| 新建 | `tests/unit/test_stall_hedge.py` | 批次 D 中段对冲编排用例(与 1.3.7 `test_hedge.py` 同设施范式,不动该文件既有断言) |
| 修改 | `tests/unit/test_types.py` `test_retry.py` `test_config.py` `test_client.py` `test_telemetry.py` `test_ports.py` | 批次 B/C/E 与 fake 同步(§2.2/§2.3);`test_telemetry.py` 冻结列清单按 T3 扩展(:53-99、:265、:295-297、:374 等断言点) |
| 修改 | `tests/unit/test_backpressure.py:213` `test_ports.py:75` `test_client.py:224/1827` `test_retry.py:82/112` `test_hedge.py:55` `tests/integration/test_redis_cross_connection.py:78` `tests/e2e/conftest.py:285` `tests/unit/test_live_evidence.py:280/1270` `tests/unit/test_usage_source_domain.py:142` | T1 端口签名同步(e2e 包装**转发** `content_event`;详见 §2.2 全量清单) |
| 修改 | `tests/integration/test_postgres_telemetry.py` | `_EXPECTED_COLUMNS`(:33-77 邻域)追加两列;`_CALL_OBSERVABILITY_COLUMNS`(:84)为派生式自动纳入,manual 档告警比对用例随动核对 |
| 修改 | `CHANGELOG.md`、`README.md`、`.env.example`、`research-wiki/ARCHITECTURE.md`、`research-wiki/ROADMAP.md` | T5 文档与登记(§4 T5) |
| 新建 | `research-wiki/findings/2026-09-11-streaming-stall-validation.md` | 红绿、命令、豁免索引,≤300 行 |
**不改**:`errors.py`/`deadline.py`/`streaming.py`/`middleware/{ratelimit,breaker,structured,cache,admission}.py`/`backends/**`/`embedding.py`/`ocr.py` 链路本体(仅其 emit_attempt 调用点经默认 None 自然兼容,零改动)/`tests/contracts/**`;`StallClock`/`backoff_delay`/`settle_and_release`/`_combine_failures` 逐字不动。若必须突破本清单,先说明最小原因交父会话核定。
### 2.2 T1 同步清单:`Transport.complete` 加 `content_event`(真实 grep 结果)
执行于 2026-09-11,命令:`grep -rn "def complete\|first_token_event" tests/ src/ | grep -v __pycache__`(剔除 `tests/outputs/` 历史日志后)。`content_event` 与 `first_token_event` 同签名形态(必填 kw、`asyncio.Event | None`),下列每一处都要同步。
**实现/协议(11 处 `def complete`)**:
| # | 位置 | 处置 |
| --- | --- | --- |
| 1 | `src/polygateway/ports.py:56`(Transport 协议) | 加 `content_event` 必填 kw + docstring(None = 不观测增量 chunk;非流式永不置位;对冲路恒 None) |
| 2 | `src/polygateway/transports/openai_compat.py:427`(真实实现) | 加参透传 `_complete_stream`(:469)/`_complete_once`(:473)两路径 |
| 3 | `src/polygateway/transports/openai_compat.py:560`(`_complete_stream`) | 加参;每增量 chunk 置位(:587-588 首 token 块邻域扩展为每 chunk) |
| 4 | `src/polygateway/transports/openai_compat.py:666`(`_complete_once`) | 接收但**永不置位**,docstring 明写 |
| 5 | `tests/unit/test_retry.py:82`(FakeTransport) | 签名加 `content_event`,不给默认值(test_retry.py:71-73 既有注释同款纪律) |
| 6 | `tests/unit/test_retry.py:112`(第二个 fake) | 同上;:123 调用处转发 |
| 7 | `tests/unit/test_hedge.py:55`(事件驱动假 transport) | 加参并**逐次记录身份**(:50/:59 同款,供"对冲路恒 None"断言) |
| 8 | `tests/unit/test_backpressure.py:213` | 加参忽略 |
| 9 | `tests/unit/test_ports.py:75` | 加参忽略 |
| 10 | `tests/unit/test_client.py:224` 与 `:1827` | 加参忽略 |
| 11 | `tests/integration/test_redis_cross_connection.py:78` / `tests/e2e/conftest.py:285` | e2e/conftest 包装必须**转发** `content_event`(:294/:306 同款);integration fake 加参忽略 |
**调用点(11 处)**:
| # | 位置 | 处置 |
| --- | --- | --- |
| 1 | `src/polygateway/middleware/retry.py:275`(单路路径) | T1 先传字面 `content_event=None`;T4 接线真事件 |
| 2 | `src/polygateway/middleware/retry.py:344`(`_attempt` 内) | 透传 `_attempt` 的 `content_event` kw |
| 3 | `src/polygateway/middleware/retry.py:436`(对冲原路) | T4 接线:流式且 `stall_after_s` 已设才给真事件,否则 None |
| 4 | `src/polygateway/middleware/retry.py:484`(对冲路) | 恒 `content_event=None`(N3③,v1 不梯次) |
| 5 | `src/polygateway/transports/openai_compat.py:469/:473` | 两路径透传(见实现 #2) |
| 6 | `tests/unit/test_openai_compat.py:84/:93`(`_complete` helper) | helper 加 `content_event=None` 默认转发(测试设施,与生产端口"必填无默认"约定不冲突) |
| 7 | `tests/unit/test_live_evidence.py:280` 与 `:1270` | 两直调传 `content_event=None`;不传则必填 kw 报 TypeError,unit 门必红 |
| 8 | `tests/unit/test_usage_source_domain.py:142` | 直调传 `content_event=None` |
### 2.3 T3 同步清单:`TelemetryRecorder.record_llm_call` 加两参(真实 grep 结果)
执行于 2026-09-11,命令:`grep -rn "record_llm_call" tests/ src/ --include="*.py" | grep -v __pycache__`(剔除 `tests/outputs/` 历史日志)。
| # | 位置 | 处置 |
| --- | --- | --- |
| 1 | `src/polygateway/ports.py:306`(协议) | 末尾追加 `stall_ms: int \| None`、`stall_count: int \| None`(**无默认值**,与既有 36 参同款约定);docstring "36 字段"→"38 字段"。**本签名是装配闸唯一事实源**,漏改即全部 `**fields` recorder 在装配期 `signature.bind` 炸——这是有意的(1.3.5 先例) |
| 2 | `src/polygateway/middleware/telemetry.py:618`(唯一调用点 `_record`) | 显式传 `stall_ms=`/`stall_count=`;`_record` 签名加两参 |
| 3 | `src/polygateway/telemetry/sqlite.py:145` | `**fields` 形态,零签名改动;行取值按探测列裁剪,新列由 schema 常量驱动自动纳入;docstring 计数同步 |
| 4 | `src/polygateway/telemetry/postgres.py:411` | 同上 |
| 5 | `tests/unit/test_ports.py:102`(`_DummyRecorder`,**显式全签名**) | +2 参(它镜像协议全签名,不加则与本协议脱节) |
| 6 | `tests/unit/test_telemetry.py:3327/:3349/:3354`(装配闸**负例** fake,故意残缺签名) | **不改**——协议加参后它们仍不满足签名,负例断言照常通过;T3 绿态核对这三条仍绿 |
| 7 | `**fields` fake(13 处):`test_ocr_client.py:149`、`test_pricing.py:131`、`test_openai_compat.py:106`、`test_embedding.py:246`、`test_cache.py:562`、`test_client.py:528/955/968/1516/1842`、`test_usage_source_domain.py:71`、`test_telemetry.py:1191/2059` | **零改动**自动兼容;批次 C 断言落行时用其中一份捕获 fields |
| 8a | 发射器 fake:`tests/unit/test_backpressure.py:233`(`*args, **kwargs` 形态) | **零改动**(`emit_attempt` 新参默认 None,见 §3.4 取舍) |
| 8b | 发射器 fake:`tests/unit/test_hedge.py:122`(RecordingEmitter,显式全签名 8 kw,无 `**kwargs`) | **列入 T3 同步清单**(审查 B1):加 `stall_ms=None, stall_count=None` 两 kw——T3 后 `_emit` 显式转发两 kw,不加则 TypeError;"新参默认 None"只护调用方、护不住被调的显式签名 fake |
## 3. 跨任务接口(可执行定义,禁止占位)
### 3.1 T1:观测原语与端口契约(P1 底层 + P2 信号源)
**端口签名**(ports.py:56-66,顺序追加在 `first_token_event` 后,**必填、不设默认值**,同款约定):
```python
async def complete(
self, *, messages, source, stream, overlay, call_id, reasoning_effort,
first_token_event: asyncio.Event | None,
content_event: asyncio.Event | None,
) -> TransportResult: ...
```
docstring 补:`None` = 调用方不观测增量 chunk(未启用中段对冲);**流式**实现每到达一个增量 chunk(`content` 或 `reasoning_content`,与看门狗/首 token 活性口径一致)置位一次;空 chunk/usage 帧/SSE keepalive 不算;非流式实现**永不置位**;对冲路恒收 None(v1 不梯次,N3③)。
**`TransportResult`**(types.py:560-586,追加在 `applied_effort` 后):
```python
stall_ms: float | None = None
"""超阈值相邻增量 chunk 沉默总额(毫秒,未取整;换算唯一处在 retry 层,设计 §4.2 N8)。"""
stall_count: float | None = None
"""超阈值相邻间隔个数;非流式/增量 chunk <2 → None(排除条款照 vLLM,设计 §4.2)。"""
```
**`SourceConfig.stall_threshold_ms`**(types.py `effort_fallback` 后追加,`int = 1000`):**这是钉死的传入路径裁决**。任务给的两条候选中:走 `complete` 新 kw = 设计 §7 未批准的第四次端口变更(且 §2.2 清单 22 处全部二次翻动,计量口径被耦进端口契约),**否决**;走 SourceConfig 字段与 `inter_token_timeout_s`(types.py:612)同先例——transport 在 `_complete_stream` 直接读 `source.stall_threshold_ms`,端口零新增,watchdog 成对校验的 `_validate_watchdog`(:676-681)同款落点加值域校验(正整数、拒 bool)。scope 级键 → per-source 字段的分发由装配层 `dataclasses.replace` 完成(T4,§3.5),运行时零额外接线。符合"决策逻辑与状态存储分离":字段是纯配置不可变数据,计算逻辑在 transport,编排判定在 retry。
**`_complete_stream` 原语**(:580-590 max_gap 循环邻域):循环内已有逐 chunk 迭代,追加两件零判定动作——`chunk_times.append(now)`(或等价的增量累计,见下)与 `content_event.set()`(None 跳过);**热路径不做阈值判定**(设计 §3 派生原则)。stall 计算收敛为模块级纯函数(P6):
```python
def _stall_from_chunk_times(times: list[float], threshold_ms: int) -> tuple[float | None, float | None]:
"""增量 chunk 时间戳序列 → (stall_ms, stall_count);chunk <2 → (None, None)。
沉默 = 相邻间隔**严格大于**阈值(恰好 = 阈值不减,pi-tps 口径);
尾部(末 chunk → 流结束)不计——墙钟基底口径,尾部归活跃侧(设计 §4.2 N1)。
"""
if len(times) < 2:
return None, None
total = 0.0
count = 0
for prev, cur in zip(times, times[1:]):
gap_ms = (cur - prev) * 1000
if gap_ms > threshold_ms:
total += gap_ms
count += 1
return (total, float(count)) if count else (0.0, 0.0)
```
注意:无超阈段但 chunk ≥2 时返回 `(0.0, 0.0)` 而非 None——"观测过且零沉默"与"无从观测"(None)是两回事,下游预算公式以 `stall_ms is None` 为分支条件(设计 §4.2 N2)。`_complete_stream` 装配处(:597-616 邻域)填 `stall_ms=`/`stall_count=`;`_complete_once` 走默认值 None。`max_inter_token_ms` 原语义一字不动。
### 3.2 T2:净口径记账(P1 落账)
`CallStats`(types.py:296-324,`hedge_won` 后追加):
```python
stall_ms: int | None = None
"""赢家路超阈值中段沉默总额(净口径归因;None = 非流式/chunk<2/无赢家)。墙钟 = generation_ms + stall_ms。"""
stall_count: int | None = None
"""超阈值相邻间隔个数(归因成对报告,Gregg 原则,调研 §B2)。"""
```
`_CallContext`(:328-388):`__slots__`/`__init__` 加 `_stall_ms`/`_stall_count`(初值 None);`record_generation` 增 keyword-only `stall_ms: int | None = None, stall_count: int | None = None`(chat 赢家路传入;embedding/ocr 既有调用零改动,恒 None 即"无沉默观测");`snapshot` 填两字段。
**retry 层换算唯一处**(N8):`_attempt` 成功分支在现有 sink append 处(retry.py:344-347 邻域)把 sink 元素从 `int` 升级为三元组:
```python
wall_ms = int((self._now() - gen_started) * 1000)
stall_ms_i = None if result.stall_ms is None else int(result.stall_ms) # 取整只此一处
stall_count_i = None if result.stall_count is None else int(result.stall_count)
generation_sink.append((wall_ms, stall_ms_i, stall_count_i))
```
净口径计算收敛进共享守卫 `_record_generation`(单路)与 `_register_hedge`(对冲赢家)共同调用的一个内部换算:`net = max(0, wall_ms - (stall_ms_i or 0))`(clamp 防御,N4),然后 `context.record_generation(net, accumulate=False, stall_ms=stall_ms_i, stall_count=stall_count_i)`。三条消费路径:
| 路径 | 位置 | 口径 |
| --- | --- | --- |
| 单路成功 | `__call__` :283-288 邻域(`generation_sink` 非空分支) | net + stall 两值进快照 |
| 对冲赢家 | `_register_hedge`(winner_sink 非空) | 赢家路 net + 赢家路 stall 两值;**输家沉默不进赢家快照**(设计 §4.2 排除条款) |
| 两败(无赢家) | `_register_hedge(hedge_won=False, winner_sink=None)` | 不记 generation、**stall 保持 None**(无归属);两路各自的沉默在各自 attempt 遥测行 |
非流式/chunk<2:TransportResult stall 恒 None → `net = wall`(不退减),快照 stall 两值 None(设计 §4.2 排除条款原样落地,无需分支——None 透传天然达成)。
### 3.3 T2:FakeClock 纪律下的测试设施
净口径断言用 `test_retry.py:120-127` 既有 FakeSleep/FakeClock 范式 + **自控 stall 假 transport**:fake 委托现有 FakeTransport,返回前 `clock.advance(wall)` 并在 TransportResult 上填**固定** `stall_ms`/`stall_count`(假 transport 自己报数,不经过真实计时)——真实 transport(`openai_compat` 内部 `time.monotonic`)**不注入假钟**(N4)。精确值断言只允许在这类注入钟 + 自控 stall 的用例里;真实 loop 钟用例只断下界与相对比较(1.3.7 §6 同款纪律)。
### 3.4 T3:遥测两列(P1 归因进库)
**schema.py 五编辑点**(顺序与 1.3.5 十列同款纪律;新列全部可空、无默认值、追加在末尾——旧行 NULL 表达"补列之前根本没记过这件事",不回填):
| 编辑点 | 内容 |
| --- | --- |
| ① `SQLITE_DDL`/`PG_DDL` 末尾 | `total_latency_ms` 后加 `stall_ms INTEGER` / `stall_count INTEGER`(PG 同名 `INTEGER`;**不用 REAL**——落库值是 retry 层已取整的 int,口径与 N8 一致) |
| ② `SQLITE_BACKFILL`(:113-141) | 追加 `("stall_ms", "INTEGER")`、`("stall_count", "INTEGER")` |
| ③ `_PG_BACKFILL_DECLS`(:144-166) | 追加同名两项,与 ② 逐项保序对齐(两路径物理列序不许分叉) |
| ④ `COLUMNS`(:177-215) | 末尾追加 `"stall_ms"`, `"stall_count"` |
| ⑤ `telemetry_schema_sql`(:323-372) | **零编辑**——由 ②③ 常量派生,同源再生成天然达成;列数断言测试随动(38) |
**协议与发射器**:ports.py:306 协议 +2 参(§2.3 #1);`_record` +2 参并在 :618 透传;`_AttemptUsage` +2 字段(默认 None;`of()` **不从 response 取**——LLMResponse 不增顶层字段,设计 §11-B2 裁决 (b));`emit_attempt` 加 keyword-only `stall_ms: int | None = None, stall_count: int | None = None` 显式转发。**默认值取舍**:与 `reasoning_applies` 不同——stall 漏传落 None 是**合法事实**("非流式/无沉默观测"),不是"没表态",默认 None 语义安全且保住约 50 处既有 `emit_attempt` 关键字调用点零改动(反 gold-plating);chat 成功路径漏传会被批次 C 落行断言当场抓住。`emit_cache_hit`/`emit_terminal_failure` 显式传 None(缓存命中与终态行无 transport 观测)。
**retry 侧 `_emit`**(:665-700):加 `stall_ms`/`stall_count` kw(默认 None);`_attempt` 成功分支把 §3.2 已算好的 `stall_ms_i`/`stall_count_i` 传入;失败/取消分支不传(恒 None——被取消输家无 TransportResult,`hedge_cancelled` 行 stall 恒 None,其存在性由该行表达,设计 §11-B2)。embedding.py:532/ocr.py:611 调用点零改动(默认 None 即正确语义)。
### 3.5 T4:配置两键 + 守卫 + 装配分发
**`STREAM__STALL_THRESHOLD_MS`**(计量阈值,设计 §6 第一行):
| 项 | 精确定义 |
| --- | --- |
| 保留段 | :57 `_RESERVED_SEGMENTS` 加 `"STREAM"`(防 4 段 `{SCOPE}__STREAM__{N}__*` 撞 provider 段,1.3.7 `HEDGE` 同款);3 段键天然跳过 `_load_sources`(:427-431 段数判据) |
| loader | 新增 `_load_stream(scope, env)`(:745 `_load_hedge` 邻域):`_first` + `_cast(..., "int", ...)`,origin 传实际命中键名;值域(正整数)归 `SourceConfig.__post_init__` 一处(字段在 §3.1 已钉死);返回 `{"stall_threshold_ms": ...}`,未设 = 1000 |
| 字段 | `GatewaySettings` :196 后加 `stall_threshold_ms: int = 1000` |
| 分发 | 装配层把 scope 值经 `dataclasses.replace(source, stall_threshold_ms=...)` 写进每个 SourceConfig:`from_settings`(client.py:485-526)恒分发(settings 值权威);`GatewayClient.__init__` 加 keyword-only `stall_threshold_ms: int | None = None`,**None = 保留各 SourceConfig 自带值**(不 stomp 直构造调用方的 per-source 设置),非 None 才分发;replace 触发`**post_init**` 重跑,值域校验复用同一处 |
**`HEDGE__STALL_AFTER_S`**(对冲触发,设计 §6 第二行 + §11-N5/B4):
| 项 | 精确定义 |
| --- | --- |
| loader | `_load_hedge`(:745-769)扩读 `STALL_AFTER_S`:`_first` + `_cast` + `ensure_call_deadline`(origin 传实际命中键名),返回字典加 `"hedge_stall_after_s"` 键;未设 = None(关闭) |
| 字段 | `GatewaySettings` 加 `hedge_stall_after_s: float \| None = None`;`_validate_hedge`(:382-392)扩传 |
| 守卫(全部落进 `check_hedge_assembly` :772-849,签名加 keyword-only `stall_after_s` 参;**返回契约钉死(审查 B2)**:返回 `tuple[float | None, float | None]`= (归一化 after_s, 归一化 stall_after_s),`config.py:384` 与 `client.py:252`两处调用点随之解构,`test_config.py:1174/:1184`列入 T4 同步清单(`is None` 断言按新返回形态改写);**两阈值皆 None 才早退**——"同 `after is None` 早退形态"字面执行会让仅 stall 配置绕过守卫②③④,禁止) | ①值域 = `ensure_call_deadline` 同款(有限正数);②`stall_after_s ≥ min over sources of (inter_token_timeout_s or timeout_s)`(有效最小值,与 openai_compat.py:573 回落同款)→ **ValueError**(对冲窗永不开,装配期直接拒绝;stall 场景无非流式残留收益,warning 论证不足,N5);③`stall_after_s ≥ call_deadline_s`(两者皆设)→ ValueError(与 `after_s` 同款,config.py:825-828 邻域);④单源 scope 设阈值 → warning 允许(1.3.7 §5 同款);⑤**B4**:Embedding/OCR scope 装配 warning 落 `EmbeddingSettings.__post_init__`(:865-869 邻域)与 `OcrSettings`(新增 `__post_init__`):内嵌 gateway 的 `hedge_after_s` **或** `hedge_stall_after_s` 非 None → warning"对冲键仅对 chat scope 生效,本 scope 非流式静默退化"(1.3.7 `AFTER_S` 同病顺带补齐,缺陷修复不扩公共面);chat 侧装配 warning 明确不做(per-call stream,装配期无信号,B4 裁决) |
| client | `GatewayClient.__init__` 加 `hedge_stall_after_s: float \| None = None` keyword-only,:252-257 守卫调用扩传;RetryMW 构造(:264-283)传 `stall_after_s=` 归一化值(取守卫返回二元组第二元,B2);`from_settings`(:525 邻域)透传 |
### 3.6 T4:retry 编排改动(P2 唯一实现形态)
`RetryMW.__init__` 加 keyword-only `stall_after_s: float | None = None`(存 `self._stall_after_s`;守卫在装配层,本类不重复校验——同 `hedge_after_s` 先例)。
**`__call__` 入口门**(:273-283 重构,设计 §11-B3):
```python
if self._hedge_after_s is None and self._stall_after_s is None:
# 两阈值均未设: 逐字 1.3.7 单路路径(默认关闭回归门据此成立)
outcome = await self._attempt(..., first_token_event=None, content_event=None, ...)
elif self._hedge_after_s is None and not request.stream:
# 仅 stall 已设且请求非流式: 中段沉默无非流式概念,逐字走单路(等价未配置)
outcome = await self._attempt(..., first_token_event=None, content_event=None, ...)
else:
outcome = await self._attempt_hedged(request, picked, reasons, attempt_fails)
```
**`_attempt_hedged` 接线**:Phase 1 原路事件的构造改为——`first_token` 恒建(AFTER_S 已设时要等它;仅 stall 时阶段 2 入口也要先等它,见下);`content_event = asyncio.Event() if (request.stream and self._stall_after_s is not None) else None`,随 `content_event=` 传入 `_attempt`。Phase 4 对冲路恒 `first_token_event=None, content_event=None`(N3③)。Phase 5 赢家裁定/收口/**"先了结的是失败则等另一路"**/`_combine_failures`/两败一次预算**逐字复用**——主路被看门狗掐死(Transient)时落入既有"先了结的是失败"分支等副路,attempt 级记账(breaker `record_failure`/`attempt_fails`/遥测 error 行)照常(设计 §11-N9;不对称声明:掐死喂熔断 vs 输家取消不喂,本版不改任一)。
**`_past_hedge_window` 两阶段重构**(:533-562;waiter 收口 + `cancelling()` 穿透纪律逐字保留,签名加 `content_event`):
```python
async def _past_hedge_window(self, primary, first_token, content_event) -> bool:
# 阶段 1 首 token 前: AFTER_S 已设才等(设计 §5.2;仅 stall 时此阶段不触发,
# 但仍须等首 token 到达才能进阶段 2——否则 pre-TTFT 沉默会被 stall 窗误吞)
if self._hedge_after_s is not None:
<既有 waiter 形态: asyncio.wait({primary, waiter(first_token)},
timeout=self._hedge_after_s, FIRST_COMPLETED),finally 收口 + cancelling 穿透>
if not primary.done() and not first_token.is_set():
return True # 首 token 超时未至 → AFTER_S 触发
else:
<同形态 waiter 但无 timeout: 只等 first_token 或 primary 了结>
if primary.done():
return False
# 阶段 2 首 token 后: STALL_AFTER_S 循环等待;非流式/未设 stall → 不开窗
if self._stall_after_s is None or content_event is None:
return False
while not primary.done():
waiter = asyncio.create_task(content_event.wait()) # 每轮自建 waiter(N3④)
try:
await asyncio.wait({primary, waiter}, timeout=self._stall_after_s,
return_when=asyncio.FIRST_COMPLETED)
finally:
<waiter.cancel() + await 收口 + cancelling() 穿透,逐字套用既有范式>
if primary.done():
return False
if content_event.is_set(): # N3① 复检: chunk 与超时同 pass 到达 → 防误触发
content_event.clear() # N3② check→clear 之间无 await
continue
return True # 复检仍未置位 = 真沉默 → 触发对冲
return False
```
红线:阶段 2 的 `clear()` 只许出现在 `is_set()` 为真且两者间无 await 的紧邻位置;每轮 waiter 必须进 finally 收口;外部取消在任一阶段穿透两路(`_attempt_hedged` 既有 `except BaseException` 收口块不动)。
## 4. 任务与提交点(T1→T2→T3→T4→T5;T3 可并 T2 后)
> 依赖顺序:T1(端口+原语)→ T2(记账,消费 T1 的 TransportResult 字段)→ T3(遥测,消费 T1/T2 的口径;可在 T2 后并行,因只碰 telemetry/ports/retry._emit 邻域)→ T4(编排+配置,消费 T1 的 content_event 与 T2 的快照口径)→ T5(文档,需前四者行为冻结)。每任务卡:目标 / 改动文件:行号邻域 / 先失败测试清单 / 证据出口 / 验收命令 / 回滚点。所有命令 `conda run -n PolyGateway`,**禁接管道**(退出码失真教训);证据文件不提交。
### T0:设计 B4 补遗与本计划(本任务,无代码)
产出:设计 §6 配置表 STALL_AFTER_S 行修正 + §11 补 B4 行(**已完成**,2026-09-11 PM 裁决)+ 本计划。不提交代码。
### T1 → 提交 1 `feat: add a content event and stall accounting to the transport port`
- **目标**:观测原语(增量 chunk 时间戳 + content_event)+ 端口契约(`complete` 加 kw、`TransportResult`/`SourceConfig` 增字段)+ stall 纯函数;§2.2 同步清单全量落地。
- **改动**:`ports.py:56-66`;`types.py:560-586/590-700`;`openai_compat.py:427-473/553-616/660-705`;`retry.py:275/314/344/436/484`(T1 阶段恒传 None);§2.2 表 11 实现 + 11 调用点全量同步。
- **先失败测试清单**(批次 A;先红证据 = TypeError/ImportError/属性缺失,非断言值不符):
| 测试名 | 断言点 |
| --- | --- |
| `test_openai_compat.py::TestContentEvent::test_stream_sets_content_event_per_chunk` | 流式 N 个增量 chunk → event 被置位(每 chunk 一次;计数/逐次 is_set 观测) |
| `…::test_reasoning_chunk_counts_as_liveness` | 只流 `reasoning_content` 的流也置位 content_event 且计入 chunk 序列(B1:思考增量同样是存活证据) |
| `…::test_non_stream_never_sets_content_event` | 非流式永不置位 |
| `…::test_none_content_event_keeps_behavior` | None = 不观测,行为逐字不变 |
| `…::test_content_event_is_required_keyword` | 漏传 → TypeError(钉住必填) |
| `…::TestStallFromChunkTimes::test_gap_above_threshold_accumulates` | 构造时间戳序列 [0, 0.3, 2.0, 2.1] 阈值 1000ms → stall_ms≈1700、stall_count==1(多段序列 → 总额/次数) |
| `…::test_gap_exactly_at_threshold_not_deducted` | 间隔恰好 == 阈值 → 不减(严格大于) |
| `…::test_fewer_than_two_chunks_returns_none` | 0/1 个 chunk → (None, None)(vLLM 排除条款) |
| `…::test_tail_not_counted` | 末 chunk 之后的流结束间隔不进 stall(尾部归活跃侧,N1) |
| `…::test_empty_or_usage_frames_not_timed` | 空 chunk/usage 帧/keepalive 不产生时间戳记录点 |
| `test_types.py::test_transport_result_stall_fields_default_none` | 两字段默认 None,旧位置构造不炸 |
| `test_types.py::test_source_config_stall_threshold_validation` | 默认 1000;0/负/bool → ValueError |
- **证据出口**:`tests/outputs/138/t1/red-batch-a.log`(先红)+ `green-unit.log`(后绿)。
- **验收命令**:`pytest tests/unit/test_openai_compat.py tests/unit/test_types.py -q` → `pytest tests/unit -q`(全绿且不改一行既有断言,§2.2 同步遗漏会在此暴露为 TypeError)。
- **回滚点**:`git revert <提交1>`(同步清单全量在同一提交,revert 即全回)。
### T2 → 提交 2 `feat: redefine generation_ms as net generation time with stall attribution`
- **目标**:`generation_ms` 净口径 + `CallStats`/`_CallContext` 两归因字段 + retry 三路径赢家记账(换算唯一处)。
- **改动**:`types.py:296-324/328-388`(§3.2);`retry.py:283-288/332-360` 邻域 + `_record_generation`/`_register_hedge` 共享换算(§3.2 表)。
- **先失败测试清单**(批次 B;先红 = 字段不存在 TypeError / 断言仍得墙钟值):
| 测试名 | 断言点 |
| --- | --- |
| `test_types.py::test_callstats_stall_fields_default_none` | 两字段默认 None;仅旧参数构造 `CallStats(...)` 不炸(1.3.7 及以前兼容) |
| `test_types.py::test_callcontext_record_generation_with_stall_and_snapshot` | stall 两值经 record_generation 进 snapshot;不传恒 None |
| `test_retry.py::test_generation_ms_net_of_stall` | FakeClock + 自控 stall 假 transport:wall 推进 5.0s、stall_ms=3200 → `generation_ms == 1800` 且 `stall_ms == 3200`、`stall_count` 透传;`generation_ms + stall_ms == wall`(还原公式) |
| `test_retry.py::test_generation_ms_clamps_negative_to_zero` | stall > wall(假 transport 报 6.0s stall、wall 5.0s)→ `generation_ms == 0`(clamp,N4) |
| `test_retry.py::test_non_stream_stall_none_no_deduction` | stall None → `generation_ms == wall`(不退减),快照两值 None(排除条款) |
| `test_retry.py::test_stall_attribution_none_when_no_winner` | 两败轮次(复用 1.3.7 两败设施)→ snapshot stall 两值 None、generation_ms 不记 |
- **证据出口**:`tests/outputs/138/t2/red-batch-b.log` + `green-unit.log`。
- **验收命令**:`pytest tests/unit/test_types.py tests/unit/test_retry.py -q` → `pytest tests/unit tests/contracts -q`(全绿;对冲赢家口径断言在 T4 编排落地后补,见批次 D 末行)。
- **回滚点**:`git revert <提交2>`。
### T3 → 提交 3 `feat: add stall_ms and stall_count telemetry columns`
- **目标**:遥测两列全链路(schema 五编辑点 + 协议两参 + emitter 透传 + 两 recorder 自动拾取);§2.3 同步清单落地。
- **改动**:`telemetry/schema.py` 五编辑点(§3.4 表)+ 模块头计数 docstring;`ports.py:306-344`;`middleware/telemetry.py:199-236/396-447/449-540/560-665` + `:368` docstring 计数("组装 36 字段"→38,审查 N4);`retry.py:_emit`(§3.4);`sqlite.py:145`/`postgres.py:411` docstring 计数;`test_ports.py:102` +2 参;`test_telemetry.py` 冻结清单扩展;`tests/integration/test_postgres_telemetry.py:33-77` 冻结清单扩展。
- **先失败测试清单**(批次 C;先红 = 冻结清单已扩展而 schema 未改 → 列数/列序断言失败;协议未加参 → 装配闸 ValueError):
| 测试名 | 断言点 |
| --- | --- |
| `test_telemetry.py::TestSchemaFreeze::test_columns_and_ddl_are_frozen`(扩) | `_EXPECTED_COLUMNS` 末尾追加两列后:`len(COLUMNS) == 38`;两端 DDL 首现顺序含新列;`PG_BACKFILL[-1]` 变 stall_count 项;占位符 `$38)`/`?`×38 |
| `test_ports.py:313` 与 `test_telemetry.py:748` 硬编码计数(扩,审查 N1) | `len(params)-1 == 36` → 38;`len(physical) == 37` → 39;连带 :650/:743/:756 注释文本同步——两处列入批次 C 清单,防被实施者"发现"而非计划登记 |
| `test_telemetry.py::TestBackendColumnParity::test_new_columns_are_appended_last`(扩) | `COLUMNS[-2:] == ("stall_ms", "stall_count")` |
| `test_telemetry.py::<SQLite 落行新用例>` | emit attempt(stall_ms=3200, stall_count=2)→ SQLite 行两列恰为该值;success 非流式行两列 NULL |
| `test_telemetry.py::<迁移幂等新用例>` | 既有 1.3.5 形态表(37 物理列)开 auto_migrate recorder → ALTER 补两列不炸;**重复初始化第二次**幂等;manual 档缺列告警逐字含两条新 ALTER |
| `test_hedge.py::<取消输家行新用例>` | 既有 AFTER_S 对冲触发,输家 `hedge_cancelled` 行 `stall_ms`/`stall_count` 恒 None(被取消输家无 TransportResult,B2) |
| `test_telemetry.py` 装配闸负例三条(:3327/:3349/:3354) | **不改**,协议加参后仍绿(负例仍旧不满足签名) |
- **证据出口**:`tests/outputs/138/t3/red-batch-c.log` + `green-unit.log`;PG 实库形态由 `tests/integration/test_postgres_telemetry.py` 覆盖(slow,发布门交集见 §6)。
- **验收命令**:`pytest tests/unit/test_telemetry.py tests/unit/test_ports.py tests/unit/test_hedge.py -q` → `pytest tests/unit tests/contracts -q`。
- **回滚点**:`git revert <提交3>`(DDL 为可空加列,旧代码读新表兼容;回滚后新列闲置 NULL)。
### T4 → 提交 4 `feat: add opt-in mid-stream stall hedging for chat`
- **目标**:`HEDGE__STALL_AFTER_S` 独立生效的中段对冲 + `STREAM__STALL_THRESHOLD_MS` 装配三层可调 + 全部守卫(B4 含 1.3.7 AFTER_S 同病补齐)。
- **改动**:`config.py:57/192-196/382-392/398-427/745-769/772-849/865-869/OcrSettings 新 __post_init__`(§3.5);`client.py:240/252-257/264-283/485-526`(§3.5);`retry.py:__init__/:273-283/:415-531/:533-562`(§3.6)。
- **先失败测试清单**(批次 D;先红 = 键未识时 3 段键被 `config.py:431` 静默跳过、实际红因 AttributeError/TypeError(审查 N3 措辞修正,红仍成立)、None 断言失败、对冲未触发 `hedges==0`、挂起用例超时):
| 测试名(新建 `tests/unit/test_stall_hedge.py`,设施复用 test_hedge.py 范式:两源、event-driven 假 transport、真实 loop 钟、阈值 0.05s、容差 4–10×) | 断言点 |
| --- | --- |
| `test_stall_triggers_hedge_after_first_token` | 假 transport 首 chunk 后沉默:s1 挂起、s2 即时成功 → 对冲触发,`hedges==1`、`hedge_won is True`;`generation_ms` 为赢家路净口径(T2 批次 B 顺延的对冲赢家断言落这里) |
| `test_stall_recheck_prevents_false_trigger` | chunk 在超时到期的同一事件循环 pass 到达(0.049s 置位,阈值 0.05s)→ **不触发**,`attempts==1`(N3① 复检) |
| `test_stall_and_after_s_independent_matrix` | 参数化四格:仅 AFTER_S / 仅 STALL_AFTER_S / 两者 / 皆无 → 各格触发语义与设计 §5.2 逐字一致(仅 stall 时 pre-TTFT 沉默**不**触发——阶段 1 不开窗) |
| `test_stall_only_non_stream_degrades_to_single_leg` | 仅 stall 已设 + `stream=False` → 逐字单路,`attempts==1`、`hedges==0`(入口门) |
| `test_watchdog_kills_primary_hedge_leg_survives` | 主路首 chunk 后沉默超 `inter_token_timeout_s` 被看门狗掐死、副路已在跑 → 本轮不因主路之死而败,副路结果裁定;主路源 `failure_count + 1`(attempt 记账照常,N9)、遥测主路 error 行 + 副路正常行 |
| `test_both_fail_counts_budget_once` | 主路看门狗掐死 + 副路 Transient → `fails` 只计一次(`_combine_failures` 复用),两败轮 stall 快照 None |
| `test_external_cancel_during_stall_wait_penetrates` | stall 等待中(阶段 2 循环内)外部 `task.cancel()` → CancelledError 穿透、waiter 收口无残留任务、两路(若已发)同消;1.3.7 `test_external_cancel_cancels_both_legs` 既有断言**一字不改**仍绿 |
| `test_hedge_leg_content_event_always_none` | 对冲路收到的 content_event 恒 None(N3③,test_hedge.py:50/:59 同款逐次身份记录) |
| `test_config.py::test_stall_threshold_key_loads_three_layers` | env 键 / GatewaySettings 字段 / GatewayClient kwarg 三层均可调(§9-③ 人类要求);SourceConfig 分发经 replace 生效;未设恒 1000 |
| `test_config.py::test_stream_segment_reserved` | `STREAM` 在保留段,`LLM__STREAM__1__X` 不造源;3 段键跳过 `_load_sources` |
| `test_config.py::test_stall_after_s_guard_effective_minimum_raises` | `stall_after_s ≥ min(inter_token_timeout_s or timeout_s)` → ValueError(有效值回落同款;配 watchdog 的源用 inter_token,未配的用 timeout_s) |
| `test_config.py::test_stall_after_s_guard_deadline_conflict_raises` | `≥ call_deadline_s` → ValueError |
| `test_config.py::test_stall_after_s_single_source_warns` | 单源 warning 允许(loguru 捕获 fixture,test_config.py:31-33 既有) |
| `test_config.py::test_embedding_ocr_scope_hedge_keys_warn` | Embedding/OCR settings 内嵌 gateway 的 AFTER_S **或** STALL_AFTER_S 非 None → warning(B4,含 1.3.7 同病补齐);chat 侧无此 warning(B4 裁决) |
| 批次 E 默认关闭回归门 | 两键均不设 → `pytest tests/unit tests/contracts -q` 全绿且**不改一行既有语义断言**(例外登记——审查 B1/B2/N1:fake 签名同步、`test_config.py:1174/:1184` 返回形态断言、两处硬编码计数,均属签名/清单同步,非语义断言改动) |
- **证据出口**:`tests/outputs/138/t4/red-batch-d.log` + `green-unit.log` + `green-contracts.log` + `lint.log`。
- **验收命令**:`pytest tests/unit/test_stall_hedge.py tests/unit/test_config.py -q` → `pytest tests/unit tests/contracts -q` → `make lint`。
- **回滚点**:`git revert <提交4>`(两键默认关闭,revert 后配置键失效由 `_load_*` 缺省自然达成)。
### T5 → 提交 5 `docs: document net generation time and mid-stream stall hedging`
- **目标**:披露义务与登记(设计 §4.2/§7);无代码行为变更。
- **改动清单**:
1. `CHANGELOG.md` 未发布段:设**独立"行为变更"段**显著标注——`generation_ms` 1.3.7(墙钟)→ 1.3.8(**净生成时间** = 墙钟 − Σ 超阈值中段沉默,仍含 TTFT 与尾部),还原公式 **墙钟 = `generation_ms + stall_ms`**;正当性三行(字段发布仅 2 天/消费者唯一/遥测无账本,设计 §4.2);`max_inter_token_ms` 原语义不变;新两字段/两键/遥测两列;对冲强制措辞三句(默认关闭;开启即用配额换延迟——实测沉默 >20s 占 19.0%,触发率远高于首 token 对冲;输家可能已被上游计费);**"中段沉默对冲无业界公开先例"诚实标注**(调研 §A3);看门狗掐死喂熔断 vs 对冲输家取消不喂的不对称声明(N9)。
2. `README.md`:口径表(`generation_ms` 净口径定义/边界含 TTFT+尾部/还原公式/`stall_ms is None` 分支条件/与 NVIDIA-vLLM 基准口径不扣除沉默的显式区隔)+ 配置表两键(值域/缺省/守卫/三层调整接口)+ 能力表"中段沉默对冲(可选,超业界现状能力)"行。
3. `.env.example`:对冲两行注释后加 `# LLM__STREAM__STALL_THRESHOLD_MS=1000` 与 `# LLM__HEDGE__STALL_AFTER_S=`(缺省关闭,语义与成本说明)。
4. `research-wiki/ARCHITECTURE.md`:口径段(`generation_ms` 净口径 + 归因两字段 + 换算唯一处纪律)与对冲触发段(两阈值独立生效/两阶段触发窗/与看门狗交互)登记。
5. `research-wiki/ROADMAP.md`:1.3.8 里程碑登记。
6. `research-wiki/findings/2026-09-11-streaming-stall-validation.md`:红绿证据、命令与退出码、豁免索引(≤300 行);wiki 注册本计划与 findings。
- **证据出口**:`tests/outputs/138/t5/lint.log` + 审查/验证纪要不进 outputs(research-wiki/reviews/ 按既有惯例)。
- **验收命令**:`make lint` + `make ci`(只读验证);版本 bump/tag/构建/上传 registry **不在本计划内**,按 CLAUDE.md §4.4.1 另行执行。
- **回滚点**:文档提交独立 revert 即可。
## 5. 测试矩阵 → 任务映射(汇总)
| 批次 | 内容 | 任务 | 落点 |
| --- | --- | --- | --- |
| A | 端口加参 + 原语 + stall 纯函数(12 用例) | T1 | test_openai_compat.py / test_types.py |
| B | 净口径记账(6 用例) | T2 | test_types.py / test_retry.py |
| C | 遥测两列(冻结扩 + 4 新用例 + 3 负例核对) | T3 | test_telemetry.py / test_hedge.py / test_ports.py |
| D | 中段对冲编排(8 用例)+ 配置守卫(6 用例) | T4 | test_stall_hedge.py(新)/ test_config.py |
| E | 默认关闭回归门(不改一行既有断言) | T4 门 | tests/unit + tests/contracts 全套件 |
**设施复用核对(动手前必读)**:`test_retry.py:120-127` FakeSleep 只记录不推进时钟(推进须用例自带闭包);`test_hedge.py:41-69` 事件驱动假 transport(content_event 身份逐次记录,同款扩展);`test_config.py:31-33` loguru WARNING 捕获 fixture(caplog 抓不到 loguru);取消窗口用 `entered` Event 范式(`test_retry.py:79-89`);真实 loop 钟阈值 0.05s、容差 4–10×(`test_streaming.py` 既有范式,不标 slow);**命名区隔**(审查 N5):`test_hedge.py:150` 既有 `stall_window_s`(StallClock 预算)与新 `stall_after_s`(对冲触发阈值)是不同概念,`test_stall_hedge.py` 复用设施时注释明写;`test_telemetry.py:53-99` 冻结清单的扩展属"先红"手段(先改清单 → schema 未改即红)。
## 6. 风险节与验收门
| 风险 | 纪律 |
| --- | --- |
| **钟混用**:测试注入 FakeClock 时真实 transport 用了假钟,或沉默序列与墙钟异钟 | N4 钉死:沉默序列(transport `time.monotonic`)与墙钟(retry 注入 `now`,生产 = `time.monotonic`)生产同钟;**FakeClock 用例一律配自控 stall 值的假 transport**,真实 transport 测试不注入假钟;对冲触发只用 loop 相对时长,绝不读注入 `now`(1.3.7 §4.1) |
| **取消穿透回归**:两阶段重构动 `_past_hedge_window`,1.3.6/1.3.7 既有取消语义被破坏 | 既有取消/对冲/看门狗测试**不得改语义只许扩展**;waiter 收口 + `cancelling()` 穿透逐字保留;批次 D 外部取消用例 + 批次 E 全绿为双门 |
| **遥测 DDL 对既有实例不幂等** | 新列可空无默认追加末尾;auto_migrate 先探测后 ALTER(SQLite duplicate column 竞态已有处理);批次 C 迁移幂等用例(二次初始化)为门;manual 档裁剪 + 点名告警同源(`missing_columns_warning` 由常量派生,零编辑) |
| **端口签名同步遗漏**(1.3.7 T1 教训) | §2.2/§2.3 两表为真实 grep 全量结果;T1/T3 全套件绿为门(遗漏 = TypeError/装配闸 ValueError 必红);**不许凭记忆补点**,执行时先重跑同一条 grep 复核 |
| **换算漂移**:多处各自取整/各自减沉默 | N8:取整与净额计算全库只 retry 层一处;`_record_generation`/`_register_hedge` 共用同一内部换算;遥测列取 INTEGER 与之一致 |
| **误触发**:chunk 与超时同一 pass 到达 | N3① 复检 `is_set()`;批次 D `test_stall_recheck_prevents_false_trigger` 为门 |
| **触发率成本**:实测沉默 >20s 占 19.0%,远高于首 token 对冲 | 默认必须关闭;CHANGELOG/README 强制措辞"对冲 = 用配额换延迟";守卫②把"对冲窗永不开"的配置在装配期直接拒掉 |
| **反馈环**:计量阈值与对冲阈值自适应耦合 | 不做自适应(设计 §8-B);两阈值皆绝对值,文档明写 |
| **emit_attempt 默认 None 掩盖 chat 路径漏传** | 批次 C 落行断言(chat 成功行两列恰为传入值)为门;取舍理由已录 §3.4 |
**验收门(合并前,全部 `conda run -n PolyGateway` 禁接管道)**:
1. `make lint`(ruff --fix + import-linter;ports/types/errors 不 import 实现的铁律由契约执法)。
2. 全套件:`pytest tests/unit tests/contracts tests/integration -q`(integration 中 PG/Redis 用例按其既有标记;默认不含 slow)。
3. **slow 交集子集**(CLAUDE.md §4.4.1 第 4 步,按 diff 交集):本 diff 触及 retry(结算/取消/编排)与 telemetry → **Redis 时间语义变体**(`tests/integration/test_redis_governance_time.py` 等与取消/结算交集的 slow 变体)+ **网关冒烟**(`tests/e2e/test_smoke_gateway.py`,公开入口 `complete` 签名变更属交集)。`test_thinking_live.py` 全模型矩阵**不跑**(本 diff 未动 thinking.py/能力注册表;复用 1.3.7 有效矩阵证据并如实登记)。
4. T5 前:`requesting-code-review`(整分支)+ `verification-before-completion`(全新上下文 verifier subagent,前台)。
## 7. 阻塞矩阵与交接
| 触发条件 | 处置 |
| --- | --- |
| 需要新增设计 §9 之外的公共键/端口方法/遥测列/异常类 | **停下上报**(边界之外即未批准) |
| `asyncio.current_task().cancelling()` 语义在目标版本不符 | 3.12 语义同 1.3.6/1.3.7 已验证路径;若实测不符,改"取消标志位置于 `_attempt_hedged` 局部"方案并记 findings,**不得吞取消** |
| 复检防误触发用例在 CI 抖动 | 只断"不触发 + attempts==1"结果,不断精确时序;仍不稳则调宽阈值比(0.45×阈值置位)并记 findings,不得删复检断言 |
| 想顺手让对冲路携带 content_event(梯次) | **不做**(N3③:v1 单路;对冲路恒 None) |
| 想把看门狗掐死改成不喂熔断 / 把输家取消改成喂熔断 | **不改任一**(N9 不对称声明:前者 1.3.6 起既有语义,后者 1.3.7 已批准语义);运维"挂起率"统计须知此不对称,落 README |
| 想给 LLMResponse 加顶层 stall 字段 | **不加**(B2 裁决 (b):CallStats 已承载赢家口径,两处平铺必漂移) |
| 发现 embedding/OCR 也想用中段对冲 | **不加**(非目标 E);B4 warning 已覆盖误配场景;登记为后续 issue |
| PG 实库迁移用例无沙箱可跑 | 记 findings 豁免索引,发布门 slow 交集时补跑;不得删批次 C 的 SQLite 幂等用例顶替 |
交接物:5 个提交(T0 文档并入首个提交或独立,由 commit skill 裁定)、1 份 findings、CHANGELOG 未发布段。版本号 bump、tag、构建、上传 registry 与 wiki 同步**不在本计划内**,按 CLAUDE.md §4.4.1 另行执行(发布时按 §6 验收门 3 补 slow 交集)。
## 8. 自审
| 检查 | 结论 |
| --- | --- |
| 路径/行号/签名是否可执行无 TBD | 是——全部接入点本会话现读:`ports.py:56-66/306-344`、`types.py:296-324/328-388/560-586/590-700`、`openai_compat.py:427-473/553-616/660-705`、`retry.py:239-300/304-400/371-562/665-700`、`config.py:57/192-196/382-392/398-427/745-769/772-849/854-910`、`client.py:217-310/485-526`、`middleware/telemetry.py:199-236/396-665`、`telemetry/schema.py:22-215/323-372`、§2.2/§2.3 全部测试落点 |
| 同步清单是否真实 grep | 是——§2.2(11 实现 + 11 调用点)与 §2.3(8 类落点)均为 2026-09-11 本会话实跑结果,非凭记忆;执行时重跑复核已写进 §6 |
| 先失败后通过证据点 | 是——T1 批次 A(TypeError/ImportError)、T2 批次 B(字段缺失/仍得墙钟)、T3 批次 C(冻结清单先扩即红/装配闸)、T4 批次 D(键未识/hedges==0/超时)均先红;证据出口 `tests/outputs/138/t<N>/` 逐卡列出 |
| 设计保真 | 是——净口径/排除条款/换算唯一处/两阶段触发窗/N3 并发纪律/N9 不对称/B2 裁决 (b)/B4 操作化均逐字落自设计;无另行发明语义 |
| 取消与降级铁律 | 取消穿透路径逐字保留既有收口;准入失败静默;后端不可用冒泡;遥测写失败降级;DDL 幂等;无 shield/后台任务 |
| 反 gold-plating | 四分类/熔断/429/Lua/streaming.py/embedding-OCR 链路/LLMResponse 顶层字段/梯次对冲/自适应阈值一律不碰;recorder `**fields` 零改动;约 50 处 emit_attempt 调用点零改动 |
| 跨任务签名一致 | `content_event`(T1 端口 → T4 接线)、stall 三元组 sink(T2 定义 → T4 赢家断言)、`stall_after_s`(config 定义 → client 透传 → RetryMW 消费)、`stall_threshold_ms`(SourceConfig 字段 → 装配 replace 分发 → transport 读)四处接缝均在 §3 写出实际代码 |
| 行数 | 约 400 行 ≤1000;表格/伪代码优先;无超 15 行代码块 |
| 残余诚实标注 | 中段对冲无业界公开先例(调研 §A3,CHANGELOG 必写);`generation_ms` 语义突变为人类"窗口期最小代价"裁决放行的例外(披露义务在 T5);复检竞速窗口只能收窄不能归零(N3① 已是设计批准的最强形态) |
@@ -0,0 +1,345 @@
---
type: plan
node_id: plan:2026-10-01-issue29-openai-fidelity
title: OpenAI消息保真实现计划
date: 2026-10-01
---
# #29 OpenAI 消息保真实现计划
> 状态:计划及首轮代码独审已完成,P1/P2 均采纳;缓存空产物和测试双向依赖已修复,发布目标按人类最终指定改为 1.3.9(取代未发布的 1.4.0),最终独审与 T8 外部发布仍由父会话执行。
> 设计:[批准方向的设计落实稿](../designs/2026-10-01-issue29-openai-fidelity-design.md)。
> 目标:同一 chat 治理链返回可供下游继续工具对话的完整单 choice 协议结果,拒绝截断和 error 帧伪成功。
> 方案:增加 response_mode 与嵌套 completion;协议解析与聚合内聚在 transport 内部模块,治理层仅传模式/搬结果;旧文本默认保持兼容。
> 技术:Python 3.12+、frozen dataclass、httpx SSE、现有 asyncio 看门狗/对冲、pytest、真实 Redis;不加运行时依赖。
## 0. 工作区、审批与执行纪律
| 项目 | 已核验值 |
| --- | --- |
| 工作目录 | `/home/iomgaa/.config/superpowers/worktrees/PolyGateway/issue29-openai-fidelity` |
| 分支 | `feat/issue29-openai-fidelity` |
| 基线 | `0e29deac1a72bfee348ed2f8061ea2c33be217e8` / 1.3.8 |
| 原工作区 | `/home/iomgaa/Projects/PolyGateway`,main 上已有未提交文档与 .pi,不得覆盖或夹带提交 |
| 基线命令 | `conda run -n PolyGateway pytest tests/unit -q` |
| 基线结果 | 1627 passed in 12.25s;仅单元基线,不代表集成/slow/新增功能通过 |
所有 Python 命令用 PolyGateway conda 环境;始终从上述隔离目录执行,确认 pytest 配置 pythonpath=src,避免误测主工作区 editable 安装。不要复制主树 `.env` 到受跟踪路径;需要真实验证时显式读取已有配置,不输出秘密。reference/ 不存在于新 worktree 的跟踪树,不为规划复制/修改参考仓。
技术方向、内部原创权属和 Apache-2.0 已获用户明确批准;细化设计与计划由独立 subagent 审查,Codex 不再调用。只在新产品/权利边界未获授权时暂停。父会话掌握审查、后续编码、合并/发布权限;当前任务不执行这些步骤。
本计划不涉及参考实现迁移,保真校验不适用;涉及既有移植流式看门狗的消费面,必须对照本库 `streaming.py`、1.3.8 stall 设计逐段保持其超时/取消/换算语义,不能借工具支持改算法。
## 1. 文件结构与职责锁定
| 路径(相对工作目录) | 动作与职责 |
| --- | --- |
| `src/polygateway/types.py` | 新 ResponseMode/OpenAIChatCompletion;末尾追加 ChatRequest.response_mode、两个响应 completion |
| `src/polygateway/__init__.py` | 导出新公共类型;最终发布时同步版本 |
| `src/polygateway/ports.py` | complete 增必填模式参数;说明工具进展与单 choice 契约 |
| `src/polygateway/client.py` | chat 新模式校验、消息/overlay 快照、组合拒绝;不重写治理入口 |
| `src/polygateway/transports/_openai_completion.py`(新) | 协议结构校验、每尝试聚合、非流式快照;无 I/O/计时 |
| `src/polygateway/transports/openai_compat.py` | error 检查、解析器接线、投影/usage/空补全/DONE、统一进展事件 |
| `src/polygateway/middleware/retry.py` | 模式传入、completion 搬运;无重试/对冲算法改写 |
| `src/polygateway/middleware/cache.py` | 模式 key、嵌套序列化/重建、缺协议 miss、序列化降级 |
| `tests/unit/test_openai_completion.py`(新) | 纯协议聚合与非流式校验的源命名测试 |
| `tests/unit/test_openai_compat.py` | 真实 transport 的 SSE/非流式/error/raw/DONE/活性测试 |
| `tests/unit/test_types.py`, `test_client.py`, `test_ports.py`, `test_cache.py` | 公共类型、校验、端口、缓存往返与兼容 |
| `tests/unit/test_retry.py`, `test_hedge.py`, `test_stall_hedge.py`, `test_deadline.py`, `test_backpressure.py` | 模式传递、错误重试、工具活性/对冲、取消与结算 |
| `tests/integration/test_governance_stack.py`, `test_redis_cache.py`, `test_redis_cross_connection.py` | 完整链路、真实缓存往返、必要 fake 签名适配 |
| `tests/e2e/conftest.py` | 现有取证 transport 包装显式转传模式,不改变 thinking 矩阵判据 |
| `tests/e2e/test_openai_tools_live.py`(新) | 受控少量真实工具双轮冒烟,独立 slow 用例 |
| `tests/unit/test_package.py` | LICENSE 文件/包元数据回归 |
| `LICENSE`, `pyproject.toml` | 标准 Apache-2.0 与打包元数据;不捆绑 reference/ |
| `README.md`, `CHANGELOG.md`, `.env.example`, `research-wiki/ARCHITECTURE.md` | 新模式、限制、raw 配置、缓存迁移、同步桥接说明 |
| `research-wiki/findings/2026-10-01-issue29-license-audit.md`(新) | 分发源码第三方来源核查证据与范围 |
| `research-wiki/graph/edges.json`, `index.md`, `log.md`, `schemas/llm-calls.md`, `metrics/call-telemetry-coverage.md` | 审查通过后的设计/计划/复用遥测关系注册,不新增表或指标实体 |
其余测试若显式调用 complete 必须补参数,仅机械适配;不重构不相关 fixture。`thinking.py`/能力表/streaming.py 算法不在预期改动内。不要为“兼容 SDK”创建不存在的 openai_sdk.py。
## 2. 跨任务接口
T1 定义公共字段,T2–T5 均消费同一类型名,禁止再起 tool_response/raw_response 别名。
```python
ResponseMode = Literal["text", "openai"]
@dataclass(frozen=True)
class OpenAIChatCompletion:
"""单 choice 协议结果,message/usage 是独立 JSON 快照。"""
message: dict[str, Any]
finish_reason: str | None
response_id: str | None
usage: dict[str, Any] | None
```
公共与端口变化均是追加参数/字段:
```python
# GatewayClient.chat 的末尾 keyword-only 参数;其余参数保持原样。
response_mode: ResponseMode = "text"
# ChatRequest 末尾字段。
response_mode: ResponseMode = "text"
# Transport.complete 末尾必填 keyword-only 参数。
response_mode: ResponseMode
# TransportResult / LLMResponse 末尾字段。
completion: OpenAIChatCompletion | None = None
```
`build_cache_key` 在已有 keyword-only 参数之后增加 `response_mode: ResponseMode = "text"`,仅非 text 时参与摘要。缓存重建只依赖内核类型/本层校验,不 import transports;若复用协议类型重建的最低形态检查,把纯 JSON 快照检查放类型自身的具名构造方法,不把 transport 的 SSE 状态机下沉到内核。
T2 内部模块只由同目录 transport 消费,不成为公共导出。采用以下窄接口(内部类型在该模块定义):
```python
@dataclass(frozen=True)
class ChatDelta:
"""一帧提取结果;progress 表示真实生成进展。"""
content: str
reasoning_content: str
progress: bool
```
```python
class ChatCompletionAccumulator:
"""每次 transport 尝试独享的单 choice 聚合状态。"""
def __init__(self, *, response_mode: ResponseMode) -> None: ...
def add_chunk(self, chunk: dict[str, Any]) -> ChatDelta: ...
def finish(self) -> OpenAIChatCompletion: ...
def parse_chat_completion(body: dict[str, Any]) -> OpenAIChatCompletion: ...
```
上述 `...` 是签名约定而非待交付占位实现。聚合器抛含判据的 TransientError(operation=chat);openai_compat 负责补选中源上下文、复用有界摘要。聚合器不判断 DONE、不读时钟;transport 掌握线路完整性。text 模式可共用帧提取与进展识别,但不调用严格 openai finish,保持旧文本工具容忍边界。
## 3. 分任务与独立验收
### T0 — 审查与来源前置核查
- [x] T1 前已核查分发来源,见 findings/2026-10-01-issue29-license-audit.md;发现不兼容复制即停止。
- [ ] 父会话安排全新上下文原生 subagent 审查设计/计划,重点:单 choice、未知增量字段、身份重复/冲突、raw/managed、缓存错误降级及取消。
- [ ] 核对每条意见,修订文档并记录接受/不接受的理由;如改变用户已批边界,走主管决定,不静默改契约。
- [ ] 在隔离树注册文档关系,复用 llm-calls schema/call-telemetry-coverage 指标;不在主树运行生成器。
- [ ] 文档审查通过后才调用 commit skill 提交设计/计划;当前阶段禁止提交。
验证命令:`wc -l research-wiki/designs/2026-10-01-issue29-openai-fidelity-design.md research-wiki/plans/2026-10-01-issue29-openai-fidelity.md` → 分别 ≤400、≤1000;`git diff --check` → 无错误。Wiki 命令经 `conda run -n PolyGateway python .claude/tools/research_wiki.py --help` 核对实际参数后执行 add_entity/add_edge/rebuild_index;检查生成 diff 不改原有条目正文。
### T1 — 公共契约贯穿完整治理链
文件:types.py、__init__.py、ports.py、client.py、middleware/retry.py;单元 types/client/ports/retry;所有显式 fake complete 签名。
先补测试:默认 text 不变、新类型可导入、末尾带默认字段不破旧构造;非法 response_mode、openai+structured、n=2/0/True 在适用边界拒绝;保真消息与嵌套 overlay 脱离调用方容器,返回上一轮 assistant 和 role=tool 请求 JSON 逐字段相同。
实现:chat 校验放在 context/handler 前;选源后非法 source.extra_body.n 在 transport HTTP 前 RequestRejected;模式作为必填端口参数穿透重试/对冲,_build_response 原样搬 completion。不把模式塞进发给上游的 payload。
验收:调用级拒绝不发 HTTP;源级拒绝结算/释放正常;自定义 transport fake 看见准确模式;既有旧构造回归全绿。命令:
```bash
conda run -n PolyGateway pytest tests/unit/test_types.py tests/unit/test_client.py tests/unit/test_ports.py tests/unit/test_retry.py -q
```
先红测试须为行为断言或缺失 API 的明确证据,记录基线 SHA 与测试名;绿后提交点:`feat: add explicit OpenAI response contract`。
### T2 — 内聚协议解析器与 error 帧修复
文件:新增 _openai_completion.py、openai_compat.py;新增 test_openai_completion.py,扩展 test_openai_compat.py。
补测 SSE/非流式/缓存缺 role、非 assistant role;合法 role-only 起始帧后工具成功。先红测试至少含下表各组;复用现有 SSE/MockTransport,不引入第二套 HTTP fake 框架。
| 组 | 输入与精确预期 |
| --- | --- |
| 并行工具 | 2 工具交错 index;同帧两工具;name/arguments 分片;空白/Unicode/引号逐字相等;最终按 index 排序 |
| 身份 | id/type 同值重复只保留一次,冲突失败;缺 id/type/name/arguments、重复 id、bool/负数 index、非 function 类型失败 |
| 文本 | 同帧正文+推理均保留;缺省/null/空串区别;原消息 think 标签不剥,旧投影仍剥 |
| envelope | 顶层数组/null、choices/delta/tool_calls 非容器、坏字段类型、多 choice/非零 choice index 均受控失败 |
| 非流式 | 完整消息、扩展字段、usage/finish/id 全保留;工具参数不做业务 JSON 修复;原响应对象改动不影响快照 |
| error | 首帧 error、正文后 error+usage+DONE、error=null、error={}、salvage 配置下 error 都立即失败;text/openai 两模式参数化 |
复用现有 summarize_body/compose_message,断言异常与遥测诊断有界,不打印工具参数正文。默认文本模式不因无用工具字段的严格 finish 校验而新增失败,但不吞顶层 error。无新 HTTP 状态映射。
命令:`conda run -n PolyGateway pytest tests/unit/test_openai_completion.py tests/unit/test_openai_compat.py tests/unit/test_http_error_body.py -q` → PASS;提交点:`fix: preserve chat deltas and reject upstream error frames`。
### T3 — 完成语义、活性与非流式接线
文件:openai_compat.py、ports.py docstring;test_openai_compat.py、test_streaming.py。
先红:tool-only + DONE 成功 attempts=1;空文本且空工具失败;正文+坏工具不成功;openai 缺 DONE 即使 missing_done=salvage 仍失败;text salvage 保持;finish_reason=length 原样交回,不解析 arguments。非流式工具同样成功,两事件永不置位、ttft/stall=None。
按设计接线:一帧一进展,同帧多个字段只记一个 chunk_time;真正非空工具增量/首次身份触发事件,重复身份和 role/usage/finish/空片段不触发。看门狗仍包进展迭代器,stall 派生/净额取整不动。
测试以受控 Event/FakeClock 驱动,避免用几十毫秒竞争制造脆弱测试:持续工具片段撑过 TTFT/inter-token;只有注释/usage 必超时;工具首进展设置 first_token_event;真实停顿计入 max_inter_token/stall,两字段与正文相同口径。
命令:`conda run -n PolyGateway pytest tests/unit/test_openai_completion.py tests/unit/test_openai_compat.py tests/unit/test_streaming.py -q` → PASS;提交点:`feat: complete tool responses with unified stream liveness`。
### T4 — 完整协议缓存往返与隔离
文件:middleware/cache.py;test_cache.py、tests/integration/test_redis_cache.py。
先红测试:同消息 text/openai key 不同、旧默认 text key 字节不变;tools、tool_choice、raw effort、tool result、assistant arguments 改动均 miss;相同请求完整 completion round-trip,非流式扩展字段不丢,message 的 null/缺省可区别;usage 缺省/null 均为 None;openai 缺 completion/null/坏 message/坏工具条目 miss 后回源,不冒 dict 假装 dataclass。
检查命中历史 response_id 不变、本次 call_id 重建、attempts=0、cache_hit=true、cost=0;变更一次命中消息不污染下一次命中;旧 text 缓存继续可读。失败路径无 set;序列化/backend set 抛错 warning 后仍返回结果;CancelledError 原样穿透。
实现保真重建校验不能 import transport(import-linter 必守);内核值类型可提供纯值构造校验,协议拼接算法只保留在 transport;避免两份 SSE/tool 聚合逻辑。
命令:
```bash
conda run -n PolyGateway pytest tests/unit/test_cache.py -q
conda run -n PolyGateway pytest tests/integration/test_redis_cache.py -q
conda run -n PolyGateway lint-imports
```
真实 Redis 专用测试 namespace,沿既有 fixture 清理自有 key,不 flush 共享 DB;未配置 skip 必须记录为未覆盖,发布前补实测。提交点:`fix: isolate and round-trip OpenAI completion caches`。
### T5 — 完整链路、对冲、取消与遥测不变量
文件:test_retry.py、test_hedge.py、test_stall_hedge.py、test_deadline.py、test_backpressure.py、tests/integration/test_governance_stack.py、test_redis_cross_connection.py、tests/e2e/conftest.py;生产 retry 仅修必要搬运漏项,不改算法。
先红→绿证据须覆盖真实 OpenAICompatTransport 接入 GatewayClient,而不是只用返回预制 completion 的 fake 证明解析成功:
| 场景 | 断言 |
| --- | --- |
| 第一次部分正文/工具→error→DONE,第二次正常 | 只返回第二次 completion,attempts=2;第一次 error 的字段不会污染结果或缓存 |
| 工具持续进展 | 首 token 对冲及时止窗,中段 event 复位,未错误触发 |
| 真沉默触发对冲 | 副路完整工具结果胜出,返回自己的 id/usage,输家不泄露半成品 |
| primary 超时但副路尚在运行 | 不提前判总失败;两败仍只烧一次超级尝试预算 |
| 流中/准入中/退避中取消与 deadline | CancelledError/CallDeadlineExceeded 类型保留,permit/inflight/task 收口,TPM 保守结算 |
| 观测与降级 | 正常/失败/cache_hit 遥测沿单 helper;attempt 行/terminal 行口径不变;后端写失败不影响结果 |
命令:
```bash
conda run -n PolyGateway pytest tests/unit/test_retry.py tests/unit/test_hedge.py tests/unit/test_stall_hedge.py tests/unit/test_deadline.py tests/unit/test_backpressure.py -q
conda run -n PolyGateway pytest tests/integration/test_governance_stack.py tests/integration/test_redis_cross_connection.py -q
```
不删除“不需要失败计费”对应的现有失败遥测/attempts/结算;本任务不新增失败 token 取证。提交点:`test: cover governed tool retries hedges and cancellation`。
### T6 — 来源审计、Apache-2.0 与用户文档
文件:LICENSE、pyproject.toml、test_package.py、新 license-audit finding、README、CHANGELOG、.env.example、ARCHITECTURE;设计/计划 Wiki 注册关系。
| 工作 | 验收 |
| --- | --- |
| 来源审计复核(初审前置 T0) | 查 tracked 分发文件的移植/参考/版权注释及具体片段;列内部原创授权记录,外部只读 reference 的路径/许可/实际使用范围;未发现不等于法律认证 |
| 第三方义务 | 对实际复制片段核实许可、保留通知;发现 AGPL 不兼容复制须上报,不借用户授权覆盖第三方 |
| LICENSE | 使用标准 Apache-2.0 全文,不自行重写;版权归属沿项目元数据,不猜用户真实姓名;NOTICE 仅在实际需要时新增 |
| 包元数据 | 当前 setuptools>=68,采用 `license = {file = "LICENSE"}` 兼容现有后端;验证 wheel/sdist 包含 license,不为 PEP 639 单独提高构建依赖 |
| 文档 | 新 chat 示例 ≤15 行;response_mode 与 raw 所有权分开;单 choice、未知 SSE 方言失败、非流式、length、无 DONE 不打捞、缓存旧值迁移、取消/线程桥接限制写清 |
`.env.example` 只解释 raw-only 必须去掉源级受管意图,不加模式 env 键。README 应说明“完整协议结果”仅四字段及已定义 SSE 字段,不能暗示返回所有顶层 logprobs/created 等;上游模型使用 LLMResponse.model_reported,不把 model 配置别名当真实版本。
ARCHITECTURE 更新 §5 类型/签名、§6.2 空补全与 error、§7.1 DONE 与工具协议、§7.2/7.6 工具活性、§7.5 缓存维度,并明确这些只是批准方向的增量,不引入 Agent Loop。站点下线,无具体 Wiki 页面可同步。
验证:`conda run -n PolyGateway pytest tests/unit/test_package.py -q` 先红后绿;`git diff --check`;构建验收见 T8。提交点:`docs: document faithful chat and Apache-2.0 distribution`。
### T7 — 少量真实工具冒烟与独立验证
新增 tests/e2e/test_openai_tools_live.py,复用 tests/e2e/conftest.py 的请求取证/输出机制,不为本次重跑整个 thinking 矩阵。fixture 的 complete 显式转传 response_mode 是机械接线,不改取证判分算法。
测试使用通用无业务领域的函数,如 echo_value;强制工具选择但工具在测试内执行纯函数,库本体不执行。流式、非流式各一次工具请求,随后带原 assistant+tool 消息做第二轮;断言 ID、finish、arguments 可用和请求消息相等。并行两工具完整逐字拼接的硬门由确定性 fixture 保证,不把模型偶尔不发两个工具解释为库聚合正确。
真实测试标 slow/requires_llm,预算与超时不紧于现有 `.env` 生产配置。raw=max 使用已配支持模型的 raw-only 源;不发明新模型/API。Markdown 安全报告记录模式、源/model_reported、attempts、finish、工具数、结果/失败原因;不输出 key、敏感消息或完整参数。费用/网络异常如实呈现,不宽泛 skip。
命令:
```bash
conda run -n PolyGateway pytest -m slow tests/e2e/test_openai_tools_live.py tests/e2e/test_smoke_gateway.py -q
make ci
```
全套件由当前前台执行;若预计长跑需 tmux,禁用缓冲、保存原退出码,不在 pytest 末尾接管道。生成唯一日志路径 `tests/outputs/issue29/<任务>-<red|green>-<时间>.log`,不提交产物。无新增功能代码的当前阶段仅跑过单元基线,不预填完成框。
父会话安排全新上下文 verifier 独立查实际 diff 与测试输出。若修改了准入/取消/退避生产逻辑,补 `tests/integration/test_redis_governance_time.py` 和 `test_redis_cross_connection.py` 对应 slow 子集;仅机械传参/协议解析时记录无 Lua/退避算法交集的理由。慢测试 skip/失败不得当通过。
- [ ] 审查员检查依赖铁律、未知字段策略、旧文本回归与大对象保留成本。
- [ ] 每项行为能链接红绿日志、命令、SHA、退出码;基线失败必须归因,不能“重跑绿了”掩盖。
- [ ] 合并前测试/独立审查通过;提交均调用 commit skill,不夹带主树既有文档差异。
### T8 — 发布与 issue 外部验收(父会话持有,不由本设计任务执行)
发布版本已由人类最终指定为 1.3.9(取代未发布的 1.4.0),不能在规划中冒称已发布。遵守 CLAUDE.md §4.4.1 全步骤:README/CHANGELOG/两处版本先完成 → 合并 main/push → 合并后 lint/全套件/相交 slow → tag push → 构建/twine check → registry 上传 → 实际下载核验 → Release/挂库/包页核验。
| 发布门 | 可接受证据 |
| --- | --- |
| 构建 | `conda run -n PolyGateway python -m build` 和 twine check 成功;解包核 LICENSE、许可元数据、completion 类型/参数/解析器均在产物中 |
| 安装 | 在独立临时环境从私有索引下载/安装已发布版本;必要时 --no-build-isolation 避免索引无 setuptools;核来源/hash,不误用本地 editable |
| 外部页面 | registry 包正文与仓库链接、Gitea Release/tag 实际可见;不以 POST 返回码代替最终状态 |
| 下游 | 以发布包运行不含业务逻辑的 tool 双轮接入验收;给 dissect3 说明同 loop 线程桥接和 raw-only 配置,不在本计划越权修改其业务仓 |
| 关闭 issue | 再查询 open issue,#29 评论附版本/验收/限制与缓存迁移说明后关闭;若出现新 issue 单独定范围,不假称本计划涵盖未来问题 |
## 4. 自审清单与残余边界
| 需求 | 覆盖任务 |
| --- | --- |
| 工具双并行、消息往返、raw=max、非流式 | T1–T3、T7 |
| error+DONE 所有模式失败、attempts=2 | T2、T5 |
| 缺 DONE 不 salvage 工具、length 不修复 | T3 |
| 活性、对冲、取消、准入/结算 | T3、T5 |
| 完整缓存、模式隔离、旧默认 key 与污染迁移 | T4、T6 |
| Apache 授权与第三方来源、分发验证 | T6、T8 |
| 测试先红后绿、独立审查、外部发布完成 | 各任务、T7、T8 |
当前残余:首轮代码独审已完成,修复后待父会话最终复核;严格 SSE 方言会拒绝未定义增量字段,不承诺任意 OpenAI 兼容网关;旧文本毒化缓存只能由使用方迁移身份;公共 dataclass 嵌套 JSON 可变,靠快照隔离而非深冻结;模型真实冒烟依赖外部可用性。以上均需在文档和验收中如实保留,不当成全部自动解决。
## 5. 本轮实施记录(2026-10-01,未发布)
P1 最终 assistant role、P2 来源核查前置均已采纳,证据见来源核查 finding。
T1–T6 代码/文档/许可已落实;T7 工具真实冒烟、Redis、质量门已执行,独立代码审查由父会话接续;T8 禁止本 worker 执行。
| 验证 | 实际结果/证据 |
| --- | --- |
| 基线红 | tests/outputs/issue29/baseline-red.log:error+DONE 未抛、双文本丢 reasoning、模式 API 缺失,3 failed |
| 扩展红绿 | expanded-red.log 19 failed(git archive 0e29dea 的 src,不改主树);expanded-green.log 同19 passed |
| 防御红绿 | defense-red.log 3 failed:usage 裸错误/空 tool_calls 丢失;defense-green.log 同3 passed |
| 全量 | ci-final.log + ci-final.exit=0:1812 passed、23 skipped、110 deselected,覆盖率95%;之后仅增测试/文档,无生产变更 |
| 最后定向 | targeted-final.log:264 passed,包含后增的非流式事件/取消/managed冲突/名称分片回归 |
| 真实缓存 | test_redis_cache.py 6 passed,专用随机 key,无共享数据清理 |
| 真实工具 | live-final.log + live-final.exit=0:流式/非流式双轮2 passed,101.34s;安全Markdown在 tests/outputs/issue29/live |
| 构建 | /tmp/pgw-issue29-dist-final 的 wheel/sdist、twine check、LICENSE/源码字节核验通过;仍1.3.8本地测试产物,绝非发布 |
23 skips 为17个已转专用时间语义的 Redis 契约变体、6个缺私有图像语料的 OCR 测试;未把这些当本次通过。110 deselected 为默认 slow;未重跑全模型 thinking 矩阵,因为只机械转传端口参数、未改其判据或能力表。
未提交代码,供父会话独立验收完整工作区。conda 启动有基础环境 RequestsDependencyWarning(pytest本体无该告警),pi-lens 使用错误环境时的缺包/枚举提示已用 conda 实证排除,不据此改变依赖。
Wiki add_entity 会覆写无frontmatter同名文件,现场检查发现后已从本会话原始 tool-write 精确恢复两文档,并加 frontmatter 后保留 P1/P2 修订;没有丢失设计内容。
补充最终证据:unit-final.log 1678 passed(23.84s),smoke.log + smoke.exit=0 为既有真实 chat 冒烟4 passed(97.18s);未跑昂贵全模型矩阵。源代码最后变更早于 ci-final,后续只增测试/文档;最后单元覆盖全部新增测试。
## 6. 审查修复与1.4.0准备(2026-10-01,未发布)
已读取首轮独立报告并核验采纳 P2 空产物缓存、Minor 测试双向依赖,详见 [审查处置](../reviews/2026-10-01-issue29-cache-review.md)。本轮只在隔离 feature 树写入,主树既有脏文档未改;不合并、不 push/tag/上传、不关issue。
| 本轮命令/证据(日志均在 tests/outputs/issue29,不提交) | 实际结果 |
| --- | --- |
| 新缓存回归修复前:review-cache-red.log | 10 failed、1 passed:五种无效产物×两种缓存投影误命中;合法tool-only通过 |
| 四模块定向:review-cache-green.log | 268 passed,17.71s;包含11项新增缓存回归 |
| 真实Redis+包测试:review-redis-package.log | 13 passed,2.66s(6 Redis、7包) |
| 首次 lint / make ci | 测试import顺序失败;已修正,不作为通过证据 |
| tmux make ci:review-ci-final.log / .exit=0 | 1827 passed、23 skipped、110 deselected,317.28s;95%覆盖;格式102文件、ruff通过、依赖契约1 kept/0 broken |
| 远端 tags 与 registry simple | 最大均1.3.8,没有1.4.0;版本两处改1.4.0,README约束>=1.4.0,<2,CHANGELOG定版 |
| build / twine / 解包:review-build-final.log、review-twine.log、review-artifacts.log | 1.4.0 wheel/sdist 均通过;所有Python源码、README、标准LICENSE逐字相同,Version/License-File正确且无reference |
| README数字断言 | inspect.signature 实测 TelemetryRecorder.record_llm_call 为38字段,未凭记忆更新 |
wheel SHA256 `3186d01d7710c3b5ef727b59f95e8c2db0821277a79491ffb913dd2ce34a888d`;sdist SHA256 `b254dedf65d25673cf57986a613a1f4e9895da8b5be1e6ee241f00065a95ad90`。这些是隔离树本地构建,不是registry产物。解包探针首次使用email默认ASCII解码比较中文README产生误报,改为METADATA正文原始字节比较后通过,未改包来迎合错误探针。
跳过项仍为17个独立slow Redis时间变体及6个缺私有图像语料OCR;110 slow未选中。本轮不重复付费网关:复用前轮流式/非流式工具双轮2通过和chat冒烟4通过,当前生产改动只统一空产物判据,工具/文本正常行为已有定向与全套件证明;合并后slow发布门由父执行。构建有setuptools许可表格弃用提醒(兼容setuptools>=68设计),并有基础conda RequestsDependencyWarning;均不等于当前构建或测试失败。
按commit skill提交feature全部实现、修复、许可与发布准备,原main脏文件及运行产物不纳入。提交后交父最终独立复核;T8外部发布尚未完成,不能提前关闭#29。
## 7. 版本定案与历史文档归档(2026-10-01 EDT / 2026-10-02 UTC)
用户最终指定本次发布 __1.3.9__,取代尚未打 tag/上传的 1.4.0。上节 1.4.0 构建散列、测试结果与历史提交标题均是当时实证,保留原样,不冒充 1.3.9 产物。README 安装约束、能力标题、CHANGELOG 与两处版本同步;自定义 `Transport.complete(response_mode=...)` 适配要求及旧缓存更换 namespace/salt 提醒保持不变。CHANGELOG 使用执行环境本地日期 2026-10-01(EDT),知识库时间戳明确使用 UTC。
历史六文档独立归档提交 `ce10881`;采纳独审发现,仅补 #24 implements 边缺少的 plan/design 两节点。验证旧新节点/边/日志并集完整、120 个索引链接与计数一致、三文件仅空白/空引用格式、无冲突标记。剩余 15 个历史未注册端点以及 wiki lint 的 15 条孤立/稀疏提示均未扩大处理。原始备份 `/tmp/pgw140-release` 及 stash 保留。
本轮只准备与本地构建,不合并、不 push/tag/上传、不关闭 issue;后续独审及发布外部验收仍是硬门。相关功能代码未变,可复用 `/tmp/pgw140-release/main-ci.log` 的 1828 项通过、95% 覆盖及 `main-slow.log` 的 6 项真实网关通过(205.81s);本轮另执行包/协议定向、质量检查和 1.3.9 构建解包验证。
| 本轮证据(`/tmp/pgw139-prepare/`) | 实际结果 |
| --- | --- |
| source.log / tests.log | 显式 `PYTHONPATH=/home/iomgaa/Projects/PolyGateway/src` 确认源码位置及1.3.9;包+缓存+协议四模块276 passed,12.43s,无新增行为测试 |
| check.log | `make check` 格式、ruff、依赖契约通过,1 kept/0 broken |
| docs-validation.log / wiki-lint.log | #24两端点补齐;原新记录保全、120链接计数通过;存量15个端点缺口及15条lint提示未清理 |
| remote-tags.txt / registry-simple.html | 2026-10-02 UTC读取远端tag与匿名simple均无1.3.9/1.4.0,无版本冲突;发布前仍须复查 |
| build.log / twine.log / artifacts.log / unpacked-import.log | 1.3.9 wheel/sdist构建、twine及全Python源码/README/LICENSE逐字核验通过;解包路径独立导入成功,38遥测字段实测不变 |
本地 wheel SHA256 `45306557152fd44f8135928041e47924834c3b4039c586f72d521f51b4590dd3`;sdist SHA256 `8f455973e7fef71811e2b86f5a14cb03469d893ac9720b902f522d2b1a8dcd40`。产物位于 `/tmp/pgw139-prepare/dist`,不是已发布包。构建仅有已知许可证表格弃用提示、conda基础环境依赖警告;不为清警告增加依赖。
@@ -0,0 +1,230 @@
---
type: plan
node_id: plan:2026-10-14-issue30-31-completion-verdict
title: 统一完成判定实现计划(#30/#31)
date: 2026-10-14
---
# #30/#31 统一完成判定实现计划
**目标**: 按[设计](../designs/2026-10-14-issue30-31-completion-verdict-design.md)(下称"设计")实现统一完成判定,修复 #30/#31 并发布 1.3.10。
**方案概述**: 在 `types.py` 内核放一组纯判定函数(流完整性 → 结局分类 → 可缓存),transport 两条路径、CacheMW 读写共用;聚合器改为 null 优先 + 未知字段无损合并;`LLMResponse`/`TransportResult` 追加 `finish_reason`/`stream_end`,遥测加 `stream_end` 列。
**技术**: Python 3.12、httpx、pydantic、pytest(asyncio)、SQLite/PG 遥测、Redis 缓存。环境 `conda run -n PolyGateway`。
本计划不涉及参考实现迁移,保真校验不适用。
## 0. 执行纪律
- 分支 `fix/issue30-31-completion-verdict`。每个任务一次提交,经 `commit` skill;pre-commit 钩子会跑全套件 + ruff + "改动触及函数复杂度 < C",故**每个提交点必须全绿**,改到的既有高复杂度函数(`add_chunk` F42、`_complete_stream`/`_complete_once` C、`_rehydrate` C、`OpenAIChatCompletion.__post_init__` D)须在同一提交内拆到 B 及以下;钩子(2026-10-14 经人类同意改为只拦"本次改动触及"的块,存量 ≥C 降为告警,提交 5d2ad40)按 radon 块判定,**类块**(`ChatCompletionAccumulator` C16、`OpenAIChatCompletion` D24)同样须降到 B 及以下。不改 `OpenAICompatTransport.complete()`(C14),新增逻辑一律放在拆出的 helper 中。
- 每个任务的统一验证(与钩子一致): `conda run -n PolyGateway pytest tests/ -q`(含 integration,本地有 Redis/PG 时真跑)+ `conda run -n PolyGateway ruff check src` + `conda run -n PolyGateway radon cc -n C <本任务改动文件>`(输出必须为空,或仅含本任务未触及的存量块)。各任务列出的子集命令只作开发期快速回路。
- 测试结果门: 每个任务的新增/改写测试先在未改实现的代码上运行并记录**失败**输出(粘贴进提交说明正文的"Red:"一行,写明失败用例名与断言),再实现至通过。
- 后台长跑命令末尾禁止接管道(CLAUDE.md §3)。
## 1. 文件结构
| 文件 | 职责变化 |
| --- | --- |
| `src/polygateway/types.py` | 新增 `StreamEnd`、`FinishClass`、`_OutputSummary`、`_Verdict` 与七个纯函数(§2 列出;另 `_effective_finish`);`TransportResult`/`LLMResponse` 末尾追加两字段;`OpenAIChatCompletion` 接受 `tool_calls: None` 并复用 `_tool_snapshot_ok` |
| `src/polygateway/__init__.py` | 导出 `StreamEnd`;`__version__` → 1.3.10(T6) |
| `src/polygateway/transports/_openai_completion.py` | 聚合器: null 优先、未知字段合并、标签键、冲突路径、finish/usage 位置记录、reasoning 别名;`parse_chat_completion` 容忍 null |
| `src/polygateway/transports/openai_compat.py` | 两条路径改走判定;有界告警节流;`finish_reason`/`stream_end` 填充;投影别名 |
| `src/polygateway/middleware/retry.py` | `_build_response` 搬运两字段 |
| `src/polygateway/middleware/cache.py` | 写/读按 `_is_cacheable`;`stream_end` 校验;finish 回退 |
| `src/polygateway/ports.py` | `record_llm_call` 追加 `stream_end` |
| `src/polygateway/middleware/telemetry.py` | `_AttemptUsage.stream_end`、`_record` 搬运、两 emit 显式 None |
| `src/polygateway/telemetry/schema.py`、`sqlite.py`、`postgres.py` | 新列 + 补列 |
| tests | 新增 `tests/unit/test_completion_verdict.py`;改写/扩充 `test_openai_completion.py`、`test_openai_fidelity.py`、`test_openai_compat.py`、`test_cache.py`、`test_telemetry.py`、`test_types.py`、`test_ports.py`、`tests/integration/test_postgres_telemetry.py` 及各 `record_llm_call` 替身 |
| 文档 | README、CHANGELOG、ARCHITECTURE §6.2/§7/§14、1.3.9 设计修订指针、`schemas/llm-calls.md`、`migrations/` 三份、pyproject 版本 |
## 2. 跨任务接口(T1 定义,后续任务只消费)
```python
StreamEnd = Literal["done", "eof_after_finish", "salvaged"]
FinishClass = Literal["natural", "truncated", "filtered", "upstream_error", "unknown", "absent"]
def _classify_finish(value: str | None) -> FinishClass: ...
def _tool_snapshot_ok(message: Mapping[str, Any]) -> bool: ...
def _stream_is_complete(
*, done: bool, finish_reason: str | None, usage_after_finish: bool, usage_measured: bool
) -> StreamEnd | None: ...
```
```python
@dataclass(frozen=True)
class _OutputSummary:
any_output: bool # 设计 §4.3 "任何产物"
body_output: bool # 设计 §4.3 "正文产物"
tools_present: bool # 至少一个非 null 工具条目(仅保真有意义)
tools_ok: bool # tools_present 时 _tool_snapshot_ok 结果;无工具恒 True
@dataclass(frozen=True)
class _Verdict:
deliver: bool
error_kind: Literal["transient", "result_invalid"] | None # deliver 时为 None
code: str | None # 设计 §4.3 原因码
```
```python
def _summarize_text(content: str, thinking: str) -> _OutputSummary: ...
def _summarize_message(message: Mapping[str, Any], projected_content: str) -> _OutputSummary: ...
def _judge_outcome(*, mode: ResponseMode, summary: _OutputSummary, finish: FinishClass) -> _Verdict: ...
def _is_cacheable(
*, mode: ResponseMode, content: str, finish_reason: str | None,
stream_end: str | None, completion: OpenAIChatCompletion | None,
) -> bool: ...
```
缓存读写共用(T1 定义,T4 消费),顶层优先、回退 `completion.finish_reason`,避免读写口径分叉:
```python
def _effective_finish(finish_reason: Any, completion: OpenAIChatCompletion | None) -> str | None: ...
```
`types.py` 无 `from __future__ import annotations`: `StreamEnd`/`FinishClass` 必须定义在 `TransportResult`/`LLMResponse` 之前。
聚合器对外只读属性(T2 定义,T3 消费): `finish_reason: str | None`、`finish_seen: bool`、`usage_after_finish: bool`、`conflicts: frozenset[str]`(归一路径)、`raw_message() -> dict[str, Any]`(未校验的合并消息深拷贝,含按 index 排序组装的 `tool_calls`,与 `finish()` 同一组装函数,供 `_tool_snapshot_ok` 判定)、`finish() -> OpenAIChatCompletion`(保真快照,校验失败抛 Transient,与 1.3.9 同)。`ChatDelta` 追加字段 `reasoning: str`(别名片段,默认 `""`)。
## 3. 任务
### T1 内核判定函数与公共字段
- 文件: `src/polygateway/types.py`、`src/polygateway/__init__.py`;测试 `tests/unit/test_completion_verdict.py`(新)、`tests/unit/test_types.py`、`tests/unit/test_package.py`(导出)。
- 行为:
- `_classify_finish` 按设计 §4.1 表,大小写敏感;None → `absent`。
- `_tool_snapshot_ok`: `tool_calls` 缺省或 None → True;否则必须是 list,每个非 None 元素满足 1.3.9 规则(非空唯一 id、`type == "function"`、非空 `function.name`、字符串 `arguments`);list 中 None 元素跳过。`OpenAIChatCompletion.__post_init__` 改为调用它(并拆分以降复杂度),`tool_calls: None` 合法且原样保留键。
- `_stream_is_complete`: `done` → `"done"`;否则 `finish_reason is not None and usage_after_finish and usage_measured` → `"eof_after_finish"`;否则 None。
- `_summarize_text`: any = `content != "" or thinking != ""`;body = `content.strip() != ""`;tools_present False、tools_ok True。
- `_summarize_message`: any = 递归遍历 message 中除 `role`、`tool_calls` 外所有值存在非空字符串,或 tools_present;tools_present = `tool_calls` 为含非 None 元素的 list;tools_ok = `_tool_snapshot_ok(message)`;body = `projected_content.strip() != ""` 或 (tools_present 且 tools_ok)。
- `_judge_outcome`: 严格按设计 §4.3 九行首匹配;原因码字面量: `upstream_finish_error`、`tool_truncated`、`tool_incomplete`、`length_without_content`、`filtered_without_content`、`empty_completion`。行 2 仅 `mode == "openai"` 时生效。
- `_is_cacheable`: 设计 §6 四条,任一成立 False;`stream_end` 不在 `StreamEnd` 域且非 None → False;openai 模式且 `completion is None` → False。
- `TransportResult`、`LLMResponse` 末尾追加 `finish_reason: str | None = None`、`stream_end: StreamEnd | None = None`(放在现有最后一个字段之后,不扰动位置构造);docstring 写清 None 语义与 `usage_source` 分工。
- 包根导出 `StreamEnd`;`_has_completion_output` 本任务**保留不动**(T3/T4 迁移后删除)。
- 测试(新文件): `_classify_finish` 全值表参数化;`_stream_is_complete` 真值表(含 usage 早于 finish、usage 非 measured);`_judge_outcome` 九行 × 两模式参数化(每行至少一例,含"正文非空+坏工具+stop → transient tool_incomplete");`_summarize_message` 对 refusal-only、reasoning-only、whitespace content、未知嵌套字符串、`tool_calls: [None]`;`_is_cacheable` 四类否例 + 正例 + 非法 stream_end。`test_types.py`: `OpenAIChatCompletion` 接受 `tool_calls=None`、拒非 list、`[None]` 合法;两新字段默认 None。
- 验证: `conda run -n PolyGateway pytest tests/unit/test_completion_verdict.py tests/unit/test_types.py tests/unit/test_package.py -q` → 全过;`make lint` 无新增问题。
- [ ] 提交 `feat: add kernel completion verdict functions and response fields`
### T2 聚合器: null 优先、未知字段合并、finish/usage 记录
- 文件: `src/polygateway/transports/_openai_completion.py`;测试 `tests/unit/test_openai_completion.py`。
- 保留 `message`、`tools` 实例属性名与语义(`test_openai_completion.py:70,93` 直接依赖)。
- 行为(设计 §5、§4.2):
- 每帧: 校验 chunk 对象;`usage` 非 null 时记录为 `last_usage`,若 `finish_seen` 或同帧 choice 带非 null finish → `usage_after_finish = True`。
- choices: 保真维持单 choice/index 0 校验;文本模式取 `index` 缺省或为 0 的第一个 choice,其余忽略。
- finish_reason: 保真走 `_identity`(非字符串/冲突 → Transient,同 1.3.9);文本模式首个字符串值生效,非字符串忽略。任一模式见到非 null 即 `finish_seen = True`。
- null 五层(设计 §5.1): chunk 层 `choices: null`、choice 层 `delta: null` 视为缺省;例外: 顶层 `error` 键(含 `error: null`)仍由 `_check_chat_error` 按 error 帧拒绝(不动,保 `test_error_before_done_is_failure`)。
- delta 与工具条目、function 三层: 值为 None 的键 → 若目标无此键则 `setdefault(key, None)`(保真),不做类型校验;`tool_calls` list 中 None 元素跳过。
- 已知字段非 None 时逐字沿用 1.3.9 校验与合并(content/reasoning_content 拼接、role 必须 assistant、工具 id/type `_identity`、name/arguments 拼接)。
- 未知字段(delta 层除 role/content/reasoning_content/tool_calls;工具层除 index/id/type/function;function 层除 name/arguments): 交给 `_merge_unknown(target, key, value, path)`,规则见设计 §5.2;标签键集合 `{"index", "type", "id", "format"}`;冲突 → 从 target 删除该键、把归一路径(数组下标替换为 `[]`,如 `delta.reasoning_details[].format`)加入 `conflicts` 与本尝试"封禁路径"集合,后续同路径值丢弃。`_merge_unknown` 返回"是否并入非空字符串片段"。
- `reasoning`: 在保真模式作为普通未知字符串合并进 message;两模式都把其片段放入 `ChatDelta.reasoning`。
- progress: 1.3.9 已知字段口径 **或** 任一 `_merge_unknown` 返回 True(文本模式也调用合并函数计算活性,但合并结果不对外)。
- 新增只读属性见 §2;`finish()` 改为: 组装 message(含排序后工具)后构造 `OpenAIChatCompletion`(校验失败仍抛 Transient)。
- `parse_chat_completion` 拆为两步: `check_completion_body(body) -> tuple[dict, str | None]`(只校验 body 为对象、恰好一个 choice、index 缺省或为 0、message 为对象、保真下 finish 为字符串或 null,失败抛 Transient;返回原始 message 与 finish)与 `build_completion(message, body) -> OpenAIChatCompletion`(构造快照,失败抛 Transient)。快照构造前剔除 `tool_calls` 中的 None 元素(与流式跳过一致,避免回传 `[null]` 被上游 400);`tool_calls: null` 原样保留。
- `_merge_unknown` 按值类型拆分私有函数(标签键、字符串、标量、对象、index 数组、其他数组),每个 ≤ B。
- 拆分 `add_chunk` 为 `_take_meta`、`_take_choice`、`_take_delta`、`_take_tools`、`_take_tool` 等私有方法,每个 ≤ B。
- 测试: 改写 `test_invalid_delta_is_domain_error` 参数: 删除 `{"audio": {}}`、`{"index":0,"extra":1}` 与 null 已知字段参数(改为正例),保留 role 非 assistant、content 非字符串、tool index 非法等仍应拒绝的参数。新增: `refusal:null`+`tool_calls:null` 帧成功且快照含 `refusal: None`;只有 `reasoning` 的帧 progress=True 且 `ChatDelta.reasoning` 有值;二次构造的 OpenRouter `reasoning_details` 分片(每帧重复 `format`/`id`,`text` 分片)合并后 `format` 不重复、`text` 拼接完整;工具层嵌套对象 `extra_content={"google":{"thought_signature":"s"}}` 原样保留;数值冲突 → 键被删、`conflicts` 含归一路径、调用成功;同路径冲突后再到达的值被丢弃;形态改变(字符串→对象、index 数组→普通数组)即冲突;布尔值冲突;其他数组追加;同帧多个字段/多个未知字符串只产生一次 progress;chunk `choices:null`、choice `delta:null` 视缺省;`role:null`、`arguments:null` 视缺省;全程只有 `role:null` → `finish()` 失败;非流式 `tool_calls:[None, 合法工具]` 快照只含合法工具;`usage_after_finish` 四种顺序(finish 前 usage、finish 同帧、finish 后 usage-only 帧、无 usage);文本模式 index=1 的 choice 被忽略;重复 tool id 不拼接(既有用例保持)。
- 验证: §0 统一验证;快速回路 `conda run -n PolyGateway pytest tests/unit/test_openai_completion.py tests/unit/test_openai_fidelity.py tests/unit/test_openai_compat.py -q`。T2 期间 transport 仍调用 `parse_chat_completion` 的组合(保留为两步的薄包装),行为不变。
- [ ] 提交 `fix: merge unknown and null stream fields losslessly in completion accumulator`
### T3 transport 两条路径接入判定
- 文件: `src/polygateway/transports/openai_compat.py`;测试 `tests/unit/test_openai_fidelity.py`、`tests/unit/test_openai_compat.py`。
- 行为:
- `_complete_stream`: 流结束后先算 `usage_measured = _resolve_usage(sink.get("usage") or {})[2] == "measured"`,再 `stream_end = _stream_is_complete(done=..., finish_reason=acc.finish_reason, usage_after_finish=acc.usage_after_finish, usage_measured=...)`。
- None 且 openai → Transient `"{源} missing_done: …"`;None 且 text → 沿 `_check_done`(零产物按 `_summarize_text` 的 any 判定 early_eof;`retry` 抛;`salvage` → `stream_end="salvaged"`)。
- `salvaged` 时 usage 沿 `_resolve_stream_usage(..., salvaged=True)`;`done`/`eof_after_finish` 时 salvaged=False。
- 投影: `thinking` = 非空 reasoning_content 片段拼接,否则 `ChatDelta.reasoning` 片段拼接;再走既有 `_finalize_text`(think 标签)。
- 判定: openai → `summary = _summarize_message(acc.raw_message(), content)`;text → `_summarize_text(content, thinking)`;`verdict = _judge_outcome(mode, summary, _classify_finish(acc.finish_reason))`;非 deliver → 按 `error_kind` 抛 `TransientError` 或 `ResultInvalidError`,message 固定格式 `f"{source.name} {code}: finish_reason={fr!r}, thinking_len={len(thinking)}"`,带 `source_name`、`operation="chat"`;`ResultInvalidError` 显式 `raw_text=""`(不回显推理内容)。deliver 且 openai → `acc.finish()` 构造快照。
- 删除 `_reject_empty_completion` 与 1.3.9 的"保真缺 DONE"分支;`_check_done` 只保留文本截断语义。
- `_complete_once`: 跳过第一步。保真: 先 `check_completion_body` 取原始 message 与 finish,用原始 message(剔除 None 工具后)算 `_summarize_message` 与 `_judge_outcome`,**仅 deliver 时**调 `build_completion` 构造快照——否则坏工具 + length 会在构造时抛 Transient,到不了 `tool_truncated`。文本: finish 取 `choices[0].get("finish_reason")`,非字符串记 None;同一判定;`stream_end=None`;投影别名同上(`message.get("reasoning_content") or message.get("reasoning")`,仅取字符串)。
- 两路径 `TransportResult` 填 `finish_reason`、`stream_end`。
- 告警节流: 实例属性 `_warned_fields: set[tuple[str, str]]`,上限 1024,饱和时记一次 `"告警集合已饱和,后续同类告警省略"`;对 `acc.conflicts` 中每个路径、以及 `_classify_finish == "unknown"` 的 finish 值(截断 64 字符,文案写明"该 finish_reason 不缓存")各告警一次;日志只含源名与路径/值,不回显字段内容。
- 拆分 `_complete_stream`/`_complete_once` 至 ≤ B(建议抽 `_consume_stream`、`_verdict_or_raise`、`_project_text`)。
- 测试(假 SSE,复用文件内既有 helper):
- #31 验收 1-6(设计 §10),断言 `stream_end`、`usage_source`、attempts=1(经 GatewayClient 或 RetryMW 的既有测试设施)。
- usage 早于 finish + EOF → 失败;finish 后 usage `{}` → 失败;`network_error`+usage+EOF → Transient。
- 文本模式: stop+usage+EOF 无 DONE(`missing_done="retry"`)→ 成功 `eof_after_finish`;无 finish + EOF → retry 抛、salvage 记 `salvaged`。
- #30 验收: 保真 reasoning-only+length → 交付,`completion.message["content"]` 缺省或空、`reasoning_content` 完整、`finish_reason=="length"`;文本同输入 → `ResultInvalidError` 且 message 含 `length_without_content`;保真 content 空+length(无任何产物)→ 交付;文本同 → ResultInvalid;保真/文本 零增量+DONE(带 stop、不带 finish)→ Transient(`test_stream_zero_content_with_done_is_transient` 保持不改);非流式 message 带 `tool_calls:null`+`refusal:null` → 成功;工具缺 name + length → ResultInvalid `tool_truncated`;content 非空 + 坏工具 + stop → Transient。
- 只有 `reasoning` 字段的流不触发 TTFT 超时(用既有看门狗测试设施设置小 ttft)且文本 `thinking` 等于拼接值;同时有非空 reasoning_content 时以其为准。
- 冲突/未知 finish 告警各只一次(loguru sink: `logger.add(list.append, level="WARNING")`,先例 `test_openai_compat.py:877`;caplog 抓不到 loguru);饱和用例预先往 `_warned_fields` 灌 1024 条再触发一次,断言饱和告警一次。
- 结局表传输层覆盖: 设计 §4.3 九行 × 两模式,每行至少流式一例 + 非流式一例(行 2 只保真);非流式"工具缺 name + length → ResultInvalid `tool_truncated`";文本 length 用例经 GatewayClient 断言 attempts=1 且熔断窗口失败计数为 0;#30 reasoning-only+length 经 GatewayClient 断言 attempts=1。
- 改写 `test_openai_never_salvages`: 断言"无 finish 的 EOF 在 `missing_done=salvage` 下仍失败",并新增"finish+usage+EOF 成功"作为对照。
- 验证: `conda run -n PolyGateway pytest tests/unit -q` → 全过。
- [ ] 提交 `fix: judge stream completion by finish reason and trailing usage`
### T4 retry 搬运与缓存读写判据
- 文件: `src/polygateway/middleware/retry.py`、`src/polygateway/middleware/cache.py`、`src/polygateway/types.py`(删除 `_has_completion_output`);测试 `tests/unit/test_cache.py`、`tests/unit/test_retry.py`、`tests/integration/test_redis_cache.py`。
- 行为:
- `_build_response` 搬运 `finish_reason`、`stream_end`。
- 写路径: 序列化前 `_is_cacheable(mode=request.response_mode, content=response.content, finish_reason=response.finish_reason, stream_end=response.stream_end, completion=response.completion)` 为 False → `logger.debug` 跳写。
- 读路径(`_rehydrate`): 重建 completion 后、`fields.update` 之前: `stored_end = fields.get("stream_end")` 非 None 且不在域 → miss+warning;`finish = fields.get("finish_reason")`,非 None 且非 str → miss;为 None 且 completion 存在 → 回退 `completion.finish_reason`;`_is_cacheable(...)` False → miss+warning;命中覆盖加 `stream_end=None`、`finish_reason=finish`。拆分 `_rehydrate` 至 ≤ B。
- 删除 `_has_completion_output` 及其 import(全仓 grep 为零)。
- 读写判定的 finish 统一取 `_effective_finish(response.finish_reason, response.completion)`(读路径对存储值同样调用)。
- 测试: 不可缓存六类(salvaged、reasoning-only length 保真、文本 length 带正文、unknown finish、保真 reasoning-only + stop(交付但无正文产物)、`content_filter` 带正文)均不写入(fake cache backend 断言 set 未调用)且第二次调用 attempts=1;手工注入上述条目 → 读视 miss;非法 `stream_end` 注入 → miss;1.3.9 旧文本条目(无新键)→ 命中;1.3.9 保真条目(无顶层 finish、completion.finish_reason="length")→ miss;命中返回 `stream_end is None`、`finish_reason` 为存储/回退值;retry 测试断言 LLMResponse 两字段来自 TransportResult。Redis integration 一例往返两新字段。
- 验证: `conda run -n PolyGateway pytest tests/unit -q` → 全过;`conda run -n PolyGateway pytest tests/integration/test_redis_cache.py -q`(需本地 Redis)→ 全过。
- [ ] 提交 `fix: gate cache writes and reads on a shared cacheability verdict`
### T5 遥测 `stream_end` 列与 recorder 端口
- 文件: `src/polygateway/ports.py`、`src/polygateway/middleware/telemetry.py`、`src/polygateway/telemetry/schema.py`、`sqlite.py`、`postgres.py`;测试 `tests/unit/test_telemetry.py`、`tests/unit/test_ports.py`、`tests/integration/test_postgres_telemetry.py`,以及全仓 `def record_llm_call` 测试替身(`grep -rn "def record_llm_call" tests`)。
- 行为:
- `TelemetryRecorder.record_llm_call` 在 `stall_count` 之后追加 keyword-only `stream_end: str | None`(无默认),docstring 注明 1.3.10。
- `_AttemptUsage` 加 `stream_end: str | None = None`,`of()` 读 `response.stream_end`;`emit_attempt` 把它传给 `_record`;`_record` 加同名参数并传给 recorder;`emit_cache_hit`、`emit_terminal_failure` 显式 `stream_end=None`。
- `schema.py`: SQLite 与 PG CREATE 末尾 `stream_end TEXT`;`SQLITE_BACKFILL`、`_PG_BACKFILL_DECLS` 追加 `("stream_end", "TEXT")`;`COLUMNS` 追加 `"stream_end"`。sqlite/postgres 若有硬编码列数或参数位次随之更新(以 `COLUMNS` 为准)。
- 替身与计数(按审查实测清单): ① 固定字段 fixture `tests/unit/test_telemetry.py:142` `_record_minimal`、`tests/integration/test_postgres_telemetry.py:121` `_minimal_fields` 补 `stream_end: None`(sqlite 按 `fields[col]` 取值,缺键 KeyError);② 硬编码计数 38→39 / 列清单追加: `test_ports.py:325`、`test_telemetry.py:53`(`_EXPECTED_COLUMNS`)`:271`、`:301-303`、`:779`、`:829`,`test_postgres_telemetry.py:33`、`:778`、`:1151`、`:1352`(执行时以 grep 复核行号);③ `test_ports.py:112` `_DummyRecorder` 补参数,`stream_end` 加入 `:332` 无默认值参数化列表;④ `**fields` 替身(14 处)无需改动。源码 docstring "38 字段"同步: `ports.py:293`、`middleware/telemetry.py:373`、`sqlite.py:146`、`schema.py:8`。形状闸 `middleware/telemetry.py:323` `_assert_recorder_shape` 从协议签名派生,不改闸本身。
- 测试: 成功流式行写 `done`/`eof_after_finish`;非流式、缓存命中、失败、取消、终态行为 NULL;旧库(无列)auto 档补列后写入;manual 档缺列 → 裁剪写入且告警点名 `stream_end`;形状闸拒绝缺 `stream_end` 参数的 recorder;PG integration 同上(需本地 PG,沿既有 fixture)。所有替身补参数。
- 验证: `conda run -n PolyGateway pytest tests/unit -q` → 全过;`conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -q` → 全过;`conda run -n PolyGateway python -c "import inspect, polygateway.ports as p; print(len(inspect.signature(p.TelemetryRecorder.record_llm_call).parameters) - 1)"` 记录字段数供 T6 使用。
- [ ] 提交 `feat: record stream end mode in telemetry`
### T6 文档、版本与另开 issue
- 文件: `README.md`、`CHANGELOG.md`、`research-wiki/ARCHITECTURE.md`、`research-wiki/designs/2026-10-01-issue29-openai-fidelity-design.md`(顶部加"§4.1/§4.2/§5/§6 部分规则已由 2026-10-14 设计修订"指针)、`research-wiki/schemas/llm-calls.md`、`research-wiki/migrations/*.md`、`pyproject.toml`、`src/polygateway/__init__.py`。
- 内容:
- CHANGELOG `## 1.3.10(未发布)`: 修复段(#31 两模式、#30 两部分、非流式 `tool_calls:null`、reasoning 别名、salvage 不再缓存);**行为变更**段(文本 length/过滤无正文 → ResultInvalid 不再经 `AllSourcesExhausted`;未知 finish 不缓存;活性口径增补);**破坏性**段(`record_llm_call` 新增必填 `stream_end`);提醒(工具快照成立+length 时 arguments 可能半截;冲突删键以告警为准;旧缓存污染迁移 namespace/salt)。
- README: 能力表补完成判定/`stream_end`;安装约束 `1.3.*` 核对;遥测字段数用 T5 实测值;活性口径一句。
- ARCHITECTURE: §6.2 空补全/missing_done 行、§7 `usage_source` 与 `stream_end` 映射、§14 协议/失败/缓存行按设计改写。
- `schemas/llm-calls.md`: 加 `stream_end` 列与取值表,修正陈旧计数。
- `migrations/` 三份: 核对下游对 `ResultInvalidError` 的 catch 点,写一行结论(有/无受影响路径)。
- 版本 1.3.10 两处一致。
- `docs-convention.md` §2 清单逐项过: Gitea Wiki 站点下线,由 README/CHANGELOG/ARCHITECTURE/schemas 承接;`.env.example` 无配置键变更(写明);`research-wiki/ROADMAP.md` 状态同步。
- 另开 Gitea issue(tea CLI): "文本模式 n>1 时多 choice 增量混流";"文本模式有产物无正文 + stop 的证据收集(决定是否改 ResultInvalid)"。
- 验证: `grep -n "1.3.10" pyproject.toml src/polygateway/__init__.py`;`conda run -n PolyGateway pytest tests/unit/test_package.py -q` → 过。
- [ ] 提交 `docs: document completion verdict and bump version to 1.3.10`
### T7 合并前验证、审查与发布
- [ ] `make lint`、`make test` 全绿(覆盖率不降);`tests/integration` 中 Redis/PG 相关全绿。
- [ ] 派全新上下文 verifier subagent(`verification-before-completion`),逐条对照设计 §4-§7、§10 与本计划验收,输出证据表。
- [ ] `requesting-code-review` 整分支审查;按 `receiving-code-review` 核验并修复。
- [ ] slow 子集(按 diff 交集): 动了公开入口与 transport → `pytest -m slow tests/e2e/test_smoke_gateway.py tests/e2e/test_openai_tools_live.py`;不跑 `test_thinking_live.py`(未动 thinking 能力表)。另做一次 magpie/glm 真实探针: 记录是否出现 `eof_after_finish`,作为 #31 外部证据。
- [ ] 按 CLAUDE.md §4.4.1 九步发布 1.3.10(CHANGELOG 定版日期、合并 main `--no-ff`、main 上重跑、tag、构建、上传、下载验证、Release + 包关联 + 页面核对)。
- [ ] 在 #30/#31 评论验收证据与版本,关闭。
## 4. 设计覆盖自查
| 设计条目 | 任务 |
| --- | --- |
| §3 公共字段、`StreamEnd` 导出、`tool_calls:null` | T1 |
| §3 recorder 端口、列 | T5 |
| §4.1 纯函数与 finish 分类 | T1 |
| §4.2 流完整性、usage 位置与 measured | T2(记录)+T3(判定) |
| §4.3 结局表、原因码、错误格式 | T1(判定)+T3(翻译) |
| §4.4 reasoning 别名 | T2+T3 |
| §5.1-5.3 null、合并、标签键、活性 | T2 |
| §5.4 告警节流 | T3 |
| §6 缓存读写 | T4 |
| §7 遥测落点 | T5 |
| §8 旧行为审计中的保留项(零增量+DONE、error 帧、多 choice) | T3 回归测试 |
| §10 文档、另开 issue、发布 | T6+T7 |
## 5. 审查修订记录
| 轮次 | 意见 | 处置 |
| --- | --- | --- |
| P1 | B1 非流式先构造快照导致 `tool_truncated` 不可达 | 采纳: T2 拆 `check_completion_body`/`build_completion`,T3 先判定后构造 |
| P1 | S1 T5 替身/计数清单 | 采纳: T5 逐项列出 |
| P1 | S2 类块复杂度、S3 验证与钩子一致 | 采纳: §0 |
| P1 | S4 保留内部属性、S5 五层 null、S6 缓存漏两类、S7 结局表与熔断断言、S8 合并规则测试 | 采纳: T2/T3/T4 |
| P1 | 建议 1-8 | 全部采纳;建议 4 选"剔除 None 工具元素" |
@@ -0,0 +1,254 @@
---
type: plan
node_id: plan:2026-10-21-issue34-ocr-document
title: OCR 整份文档解析实现计划(#34)
date: 2026-10-21
---
# #34 OCR 整份文档解析实现计划
> 实现已获批准的设计 [2026-10-21-issue34-ocr-document-design](../designs/2026-10-21-issue34-ocr-document-design.md)(下称「设计」,章节号引自它)。
> 分支 `fix/issue34-ocr-document`;基线 `25a90eb`(1.3.10);目标 **1.3.11**。**发布(合并/tag/上传 registry/建 Release/关 #34)不在本计划内,等人类逐步指令(设计 H4)。**
> 保真校验: 本计划不涉及参考实现迁移,保真校验不适用。
**目标**: 让 OCR 客户端支持整份 PDF 文档解析,返回带页码/bbox/类型/文字的逐块结果与整篇 Markdown,并修正上传类型标签与确定性坏输入的分类。
**方案概述**: 新增 `OcrClient.parse_document` 与独立 Protocol `OcrDocumentTransport`;transport 按内容嗅探 PDF 并设置上传标签;结果只从 `_middle.json` + 配对 `.md` 解析(不读 `_content_list.json`);新路径对一组已知的确定性 500 改归「请求被拒」。`parse_layout`/`recognize_text` 的签名与返回类型不动。
**技术**: Python 3.12、httpx(流式 GET)、`asyncio.to_thread`、标准库 `zipfile`/`json`;**零新依赖**。
## 0. 全局约束(每个任务都适用)
| 约束 | 内容 |
| --- | --- |
| 先红后绿 | 每个任务先写测试并**亲眼看到因目标行为缺失而失败**,红灯输出存 `scratchpad/<任务>-red.log`,再实现到绿。测试结果门要求的证据,最终报告逐条引用 |
| 提交门 | `.claude/scripts/hooks/pre-commit-guard.sh` 在提交时跑**全套** `pytest tests/` 并拒绝「本次改动触及的函数圈复杂度 ≥ C」(radon ≥ 11)。故所有新函数须拆小(见 T3 的分解),提交必须调用 `commit` skill、**不带 AI 签名** |
| 命令 | 一律 `conda run -n PolyGateway ...`;长命令输出重定向到文件并**直接取退出码**,不接管道(CLAUDE.md §3 执行模式) |
| 复杂度 | 新函数的复杂度在实现前只能标「预计」,**每次提交前对改动模块跑 `radon cc <文件> -n C`**,有输出即拆分;不得提前宣称已通过 |
| 层次 | `import-linter` 契约:`ports/types/errors` 最内层;`transports/` 只实现端口、不依赖 `middleware/`;每个任务结束跑 `make lint` |
| 中文 | 模块/类/方法中文 docstring;注释解释「为什么」;日志用 loguru,禁 `print` |
| 不顺手清理 | 只动本计划列出的文件与函数;`parse_layout` 的行为(bbox 整数化退化校验、`page_idx` 回退、无体积上限)**保持原样** |
| 基线 | 动手前 OCR 相关单测 257 passed(`test_monkey_ocr/test_ocr_client/test_ports/test_types/test_package`) |
## 1. 文件结构
| 文件 | 动作 | 职责 |
| --- | --- | --- |
| `src/polygateway/types.py` | 改 | `CallOperation`+`CALL_OPERATIONS` 增 `"parse_document"`;新增 `OcrDocumentElement`/`OcrDocumentResult`/`OcrDocumentTransportResult`;`OcrLayoutElement` docstring 加继承警示 |
| `src/polygateway/ports.py` | 改 | 新增 `OcrDocumentPort`、`OcrDocumentTransport`(**不动** `OcrTransport` 成员) |
| `src/polygateway/transports/_monkey_document.py` | **新** | 结果包解析纯函数(无 HTTP、无治理):`parse_document_package`;承接并**原样迁入** `_finite_number`/`_parse_page_size`(避免与 `monkey_ocr.py` 循环导入;`monkey_ocr.py` 反向导入它们) |
| `src/polygateway/transports/monkey_ocr.py` | 改 | `_sniff_media`/`_upload_part`/`_files`;`_classify_status`+`_translate_http_errors` 增 `rejected_markers`;`MonkeyOcrTransport.parse_document`、流式下载、第一段协议抽共用 |
| `src/polygateway/ocr.py` | 改 | `OcrClient.parse_document`;`_OcrKind` 增 `"document"`;私有方法形参 `image`→`payload`;`_invoke`/`_summarize` 分派 |
| `src/polygateway/__init__.py` | 改 | 导出 `OcrDocumentElement`/`OcrDocumentResult`(版本号放 T9) |
| `tools/check_wiki_alignment.py` | 改 | `_SIGNATURE_TARGETS` 加 `OcrClient.parse_document` |
| `research-wiki/schemas/llm-calls.md`、`metrics/ocr-call-success.md`、`metrics/call-telemetry-coverage.md` | 改 | operation 取值与摘要格式 |
| `tests/unit/{test_types,test_ports,test_package,test_monkey_ocr,test_ocr_client,test_telemetry}.py` | 改 | 见各任务 |
| `research-wiki/{index.md,log.md,graph/edges.json}` | 改(自动生成) | T7 的 `make wiki` 产物,随该任务提交 |
| `tests/unit/test_monkey_document.py` | **新** | 结果包解析的契约用例(设计 §5.1 逐行一条) |
| `tests/integration/test_monkey_ocr_document_live.py` | **新** | `slow` 活体用例(T8)。**不改**既有 `test_monkey_ocr_live.py`: 它带模块级 `skipif`(依赖图片语料)且用例未标 `slow`,默认套件会收集它(实测 6 项),混入新用例会改变提交门的行为 |
| `CHANGELOG.md`、`README.md`、`.env.example`、`pyproject.toml`、`research-wiki/{ARCHITECTURE.md,designs/2026-07-21-m3-ocr-design.md,migrations/chsanalyzer.md}` | 改 | T9 |
## 2. 跨任务关键接口(执行时以此为准)
```python
# types.py
@dataclass(frozen=True)
class OcrDocumentElement(OcrLayoutElement):
text: str # 随 type: text/title→纯文本; interline_equation→裸 LaTeX; table→HTML; image→""
@dataclass(frozen=True)
class OcrDocumentTransportResult:
elements: list[OcrDocumentElement]
page_sizes: list[tuple[float, float]]
markdown: str
raw: dict[str, Any]
@dataclass(frozen=True)
class OcrDocumentResult: # 独立定义,不继承 OcrLayoutResult(父类 call_stats 带默认值)
elements: list[OcrDocumentElement]
page_sizes: list[tuple[float, float]]
markdown: str
source_name: str
usage: Usage
latency_ms: int
call_id: str
raw: dict[str, Any]
call_stats: CallStats | None = None
# ports.py
@runtime_checkable
class OcrDocumentPort(Protocol):
async def parse_document(self, document: bytes) -> OcrDocumentResult: ...
@runtime_checkable
class OcrDocumentTransport(Protocol):
async def parse_document(
self, *, document: bytes, source: SourceConfig, call_id: str
) -> OcrDocumentTransportResult: ...
# transports/_monkey_document.py (全部无 I/O,可在线程中运行)
MAX_MIDDLE_BYTES = 64 * 1024 * 1024
MAX_MARKDOWN_BYTES = 16 * 1024 * 1024
def parse_document_package(
zip_bytes: bytes,
) -> tuple[list[OcrDocumentElement], list[tuple[float, float]], str]: ... # 失败一律 ResultInvalidError
# transports/monkey_ocr.py
_MediaKind = Literal["pdf", "image", "unknown"]
def _sniff_media(data: bytes) -> _MediaKind: ...
def _upload_part(data: bytes) -> tuple[str, bytes, str]: ...
_MAX_PACKAGE_BYTES = 256 * 1024 * 1024
_DEFAULT_REJECTED_MARKERS = ( # 小写子串;取设计 §6 实测
"failed to open stream", "cannot open empty stream",
"cannot save with zero pages", "is no pdf",
)
def _classify_status(exc, source_name, operation, rejected_markers: tuple[str, ...] = ()): ...
# ocr.py
async def parse_document(
self, document: bytes, *, session_id: str | None = None, parent_call_id: str | None = None,
tenant_id: str | None = None, meta: Mapping[str, Any] | None = None,
call_deadline_s: float | None = None,
) -> OcrDocumentResult: ...
```
## 3. 任务
### T1 内核类型与端口 — [ ] 一次提交
- **文件**: `types.py`、`ports.py`、`__init__.py`(仅导出)、`tests/unit/test_types.py`、`tests/unit/test_ports.py`、`tests/unit/test_package.py`(其显式导出名单加两个新名)。
- **行为**: 按 §2 增加三个数据类与两个 Protocol;`CallOperation`/`CALL_OPERATIONS` 增 `"parse_document"`;`OcrLayoutElement` docstring 加「追加带默认值字段前须评估子类 `OcrDocumentElement`(无默认字段会在导入期 `TypeError`)」;`OcrDocumentResult` 不继承 `OcrLayoutResult`,docstring 写明原因;`OcrDocumentResult.markdown` 的 docstring 写明「含悬空 `![](images/<sha>.jpg)` 引用——库不外传图片」,`OcrDocumentElement` docstring 写明 `text` 随 type 的含义(设计 §4.1)。
- **测试(先红)**:
- `test_document_element_is_layout_element`: `isinstance(el, OcrLayoutElement)` 为真,且 `OcrDocumentElement(type="t",bbox=(0,0,1,1),page_index=0,text="x") != OcrLayoutElement(type="t",bbox=(0,0,1,1),page_index=0)`(dataclass `__eq__` 要求同类,钉死该已知语义)。
- `test_document_types_frozen`: 三个新类型赋值抛 `FrozenInstanceError`。
- `test_call_operations_matches_literal`: `get_args(CallOperation) == CALL_OPERATIONS`(该常量此前无任何读者)。
- `test_ocr_document_ports_runtime_checkable`: 合格/缺方法的桩各一;`test_ocr_transport_members_unchanged`: `OcrTransport` 的成员集合恰为 `{recognize_text, parse_layout, check_health}`(防止有人把新方法塞进旧 Protocol);`test_ports.py:190-196` 的参数化补 `(OcrDocumentTransport, "parse_document")` 断言无 reasoning 档。
- `test_package.py` 已有导出一致性用例须覆盖两个新导出名。
- **验证**: `conda run -n PolyGateway pytest tests/unit/test_types.py tests/unit/test_ports.py tests/unit/test_package.py -q --no-cov > out.log 2>&1; echo $?` → `0`;`make lint` 通过。
- **提交**: `feat: add OCR document types and ports`。
### T2 上传类型嗅探 — [ ] 一次提交
- **文件**: `transports/monkey_ocr.py`、`tests/unit/test_monkey_ocr.py`。
- **行为**(设计 §3 Q2、§5): `_sniff_media` 判定顺序固定——① 起始为图片魔数(JPEG `\xff\xd8\xff`、PNG `\x89PNG\r\n\x1a\n`、TIFF `II*\x00`/`MM\x00*`、BMP `BM`、GIF `GIF87a`/`GIF89a`、WebP `RIFF????WEBP`)→ `"image"`;② 否则前 1024 字节内含 `%PDF-` → `"pdf"`;③ 否则 `"unknown"`。`_upload_part`: pdf → `("doc.pdf", data, "application/pdf")`,**其余(含 unknown)原样** `("image.jpg", data, "image/jpeg")`。`MonkeyOcrTransport._files` 改为 `{"file": _upload_part(data)}`,`recognize_text`/`parse_layout` 无需改动调用点。
- **测试(先红)**: 参数化 `_sniff_media`(纯 PDF、前缀 200 字节垃圾的 PDF、JPEG/PNG/TIFF 两种字节序/BMP/GIF/WebP、**起始为 JPEG 魔数但 1KB 内含 `%PDF-` 字样的图片须判 image**、HTML、乱码、空串);`test_upload_label_per_method`: `recognize_text`/`parse_layout` 对 PDF 字节发出 `filename="doc.pdf"` 与 `Content-Type: application/pdf`,对非 PDF 逐字 `filename="image.jpg"`(现有 `test_multipart_shape` 保持通过)。
- **验证**: `pytest tests/unit/test_monkey_ocr.py -q --no-cov` 全绿。
- **提交**: `fix: label PDF uploads by content instead of a fixed image name`。
### T3 结果包解析纯函数 — [ ] 一次提交(本计划最大任务)
- **文件**: `transports/_monkey_document.py`(新)、`transports/monkey_ocr.py`(仅把 `_finite_number`/`_parse_page_size` 改为从新模块导入,行为不变;新模块**不得**反向导入 `monkey_ocr`)、`tests/unit/test_monkey_document.py`(新)。
- **行为**: 严格按设计 §5/§5.1。**异常边界(Codex 审查纠正)**: 广义数据异常转换放在 **`parse_document_package` 最外层**,必须同时覆盖「读取」与「全部结构解析」——`except (ValueError, TypeError, KeyError, OverflowError, RecursionError, zipfile.BadZipFile, zipfile.LargeZipFile)` → `ResultInvalidError("OCR 文档结果包内容无效: …")`(`CancelledError` 不在其内);`_read_members` 内**仅包在 `archive.open/read` 外**的窄 `try` 另接 `RuntimeError`(加密成员)/`NotImplementedError`(未知压缩)/`zlib.error`/`EOFError`。已是 `ResultInvalidError` 的原样透传(先 `except ResultInvalidError: raise`)。
- **函数分解(预计均 < C,以提交前 radon 实测为准)**:
- `parse_document_package`: 仅编排 `_read_members` → `_parse_pages`,并持有外层异常边界。
- `_read_members(zip_bytes) -> tuple[object, str]`: 开 ZIP、取首个 `*_middle.json`、`.md` 按 `去掉 "_middle.json" + ".md"` 精确配对、读取前检查 `ZipInfo.file_size`(middle ≤ `MAX_MIDDLE_BYTES`、md ≤ `MAX_MARKDOWN_BYTES`)、`json.loads`、`utf-8-sig` 解码。
- `_parse_pages(payload)`: `pdf_info` 须 list 且**非空**;逐页 `_page_elements`。
- `_page_elements(page, position)`: 页须 dict;`_parse_page_size`;`_page_index(page, position)`(独立小函数: 须 int、非 bool、非负且等于 position,缺失同样报错);`para_blocks` 缺失=空页、非 list 报错。
- `_block_elements(block, page_index)`: 块须 dict、`type` 须 str 且 `strip()` 非空;`_loose_bbox`;父元素 + 非 `*_body` 嵌套子块各成独立元素(紧随父块、保持顺序)。
- 文字提取拆四个小函数: `_lines_text(lines)` → `_line_text(line)` → `_span_text(span)`(`content` 须 str;`inline_equation` 包 `$…$`);`_body_text(block)` 只做分派,表格走 `_table_text(sub_blocks)`(找 `table_body` 子块 → 其 span 取 `html`,无则 `latex`,皆无报错),image 走 `""`。
- **测试(先红,`test_monkey_document.py`,合成 builder,不含任何真实文本)**: 设计 §5.1 契约表**逐行一条,参数化列明各分支**:
- 页级: 零页;`pdf_info` 缺失/非 list;页非 dict;`page_size` 缺失/非 2 元/含 bool/非正/非有限/巨大整数;`page_idx` 缺失/非 int/bool/负/错位;`para_blocks` 缺失=空页、非 list。
- 块级: 块非 dict;`type` 缺失/非 str/空串/**纯空白**;bbox 长度≠4/含非数值/含 bool/非有限/反序(x 与 y 各一)/**零宽与零高均合法**/**越界页面框不被裁剪**;`lines` 非 list/行非 dict/`spans` 非 list/span 非 dict/`content` 非 str/`lines: []`→`""`;table 取 html、取 latex、皆无、无 `table_body` 子块;image(用真实结构的 `image_body`+`image_path`)→`""`;未知类型无 lines 无 blocks→`""` 且 type 保留;嵌套 caption/footnote 成独立元素且**紧随父块**(标「未经实测」);`inline_equation` 包 `$`;多行多 span 拼接。
- 包级: `.md` 缺失/非 UTF-8/带 BOM/空串;**多个 `_middle.json` 取首个**;**干扰 `.md`(名称不配对)不被取用**;`discarded_blocks` 不进结果;`need_drop=true` 的页**不失败、不影响结果**;ZIP 里放垃圾 `*_content_list.json` 与 `images/x.jpg`,输出与其无关。
- 资源与异常边界: 加密成员、未知压缩方法、CRC 损坏、声明大小超限(middle 与 md 各一)、**`10**400` 整数出现在页结构里(而非仅 JSON 层)**、深嵌套 JSON、span 内嵌套异常类型——全部 `ResultInvalidError`,**不漏出原生异常**。
- 回归: 既有 `_parse_middle_json` 对相同输入行为不变(bbox 整数化退化仍拒绝、`page_idx` 非法仍回退枚举)。
- docstring 验收: `parse_document_package` docstring 写明与 layout 路径的三处差异(零页拒绝、`page_idx` 严格、bbox 宽松)。
- **验证**: `pytest tests/unit/test_monkey_document.py tests/unit/test_monkey_ocr.py -q --no-cov` 全绿;`radon cc src/polygateway/transports/_monkey_document.py src/polygateway/transports/monkey_ocr.py -n C` 无输出。
- **提交**: `feat: parse document result packages from middle json only`。
### T4 确定性 500 窄化(仅新路径可启用) — [ ] 一次提交
- **文件**: `transports/monkey_ocr.py`、`tests/unit/test_monkey_ocr.py`。
- **行为**: `_classify_status(exc, source_name, operation, rejected_markers=())`: 当 `status == 500` 且 `rejected_markers` 非空且响应体(小写)同时含 `parsing failed:` 与任一标记 → 返回 `RequestRejectedError`(**保留 `status_code=500`**,其余 ctx 与摘要同既有);否则走原分类。判定拆成独立小函数 `_is_deterministic_failure(body, markers)`。`_translate_http_errors(source_name, operation, *, rejected_markers=())` 透传。`MonkeyOcrTransport.__init__` 增关键字参数 `deterministic_failure_markers: tuple[str, ...] = _DEFAULT_REJECTED_MARKERS`,存为 `self._rejected_markers`。**默认调用(不传 markers)行为字节级不变。**
- **测试(先红)**: 四个标记各一(用设计 §1 E5 的真实响应体形态 `{"detail":"Parsing failed: …"}`)→ `RequestRejectedError` 且 `status_code==500`、摘要保留;同标记但**不带** `Parsing failed:` 前缀 → 仍 Transient;`Parsing failed: 500: Model not initialized` → Transient;`Unsupported file type` → Transient;空 markers → 与现状一致(现有 `TestErrorTranslation` 全部通过);构造参数可覆盖标记集合;`429`/`503`/`401` 在传入 markers 时分类不变。
- **红灯预期**: 分类断言失败(返回 `TransientError` 而非 `RequestRejectedError`),不是导入/fixture 错误。
- **验证**: `pytest tests/unit/test_monkey_ocr.py -q --no-cov` 全绿。
- **提交**: `feat: let document calls treat deterministic parse failures as rejected input`。
### T5 transport.parse_document — [ ] 一次提交
- **文件**: `transports/monkey_ocr.py`、`tests/unit/test_monkey_ocr.py`。
- **行为**: 把 `parse_layout` 的「POST `/parse` → 校验 `success is True` → 取 `download_url`」抽为共用私有方法(`parse_layout` 行为与错误消息**逐字不变**,传空 markers);新增 `parse_document(*, document, source, call_id)`,并把下载拆成独立私有方法 `_download_package(client, download_url, source) -> bytes`:
1. `_sniff_media(document) == "unknown"` → `RequestRejectedError`(无 `status_code`,`operation="parse"`),**不发 HTTP**。
2. POST(传 `self._rejected_markers`)。
3. `_download_package`: `client.stream("GET")`;**先处理错误状态**——`resp.is_error` 时 `await resp.aread()` 再 `raise_for_status()`(保持响应体摘要随所有分支、保持 401/429/5xx 分类);**成功状态之后**才检查 `Content-Length` 超 `_MAX_PACKAGE_BYTES` 即拒,并对 `aiter_bytes()` 逐块累计,超限 → `ResultInvalidError`(不得让结果包资源检查改变错误分类)。
4. `await asyncio.to_thread(parse_document_package, content)`。
5. 返回 `OcrDocumentTransportResult(raw={"success": True, "download_url": …})`。
- **测试(先红)**:
- 全链路(相对与绝对 `download_url`)、`markdown` 与元素取值;`parse_layout` 既有 `TestParseLayout` 全部通过。
- **新入口上传标签矩阵**: PDF/前缀垃圾 PDF → `doc.pdf`;图片 → `image.jpg`;未知字节 → 本地 `RequestRejectedError` 且 `MockTransport` 收到 **0 次请求**。
- POST 返回四个标记之一 → `RequestRejectedError(500)`,未知 500 → Transient;`success!=true` → `RequestRejectedError(status_code=200)`;GET 的 401/429/500 响应体摘要保留且分类不变;**错误状态 + 超限 `Content-Length` 仍按 HTTP 分类**(不得变成 `ResultInvalidError`);成功状态下 `Content-Length` 超限与多块累计超限 → `ResultInvalidError`。
- **解析期间取消不占事件循环**(写法固定): `monkeypatch` 把 transport 引用的 `parse_document_package` 换成同步桩,桩内 `threading.Event.wait(timeout=5)` 阻塞,进入时用 `loop.call_soon_threadsafe(entered.set)` 通知(**不得**在线程里直接用 `asyncio.Event`);测试 `await entered.wait()`,确认另一个协程仍能运行,然后 `task.cancel()`,在释放线程事件**之前**断言 `CancelledError`;`finally` 释放并等待线程收尾。
- **验证**: `pytest tests/unit/test_monkey_ocr.py tests/unit/test_monkey_document.py -q --no-cov` 全绿;radon 同 T3。
- **提交**: `feat: add parse_document to the MonkeyOCR transport`。
### T6 OcrClient.parse_document — [ ] 一次提交
- **文件**: `ocr.py`、`tests/unit/test_ocr_client.py`、`tests/unit/test_telemetry.py`。
- **行为**: 按 §2 签名新增公共方法(与 `parse_layout` 对称): 先 `validate_caller_dimensions(origin="parse_document(tenant_id=..., meta=...)")`,再 `_document_transport()` 校验(`isinstance(self._transport, OcrDocumentTransport)`,否则 `TypeError`,**早于 `_CallContext`**)。其余改动:
- `_OcrKind` 增 `"document"`;私有方法形参 `image`→`payload`。**旧入口错误文案不变**: `_call` 的校验文案按 kind 取名(`text`/`layout` 仍是「image 必须是 bytes(路径读取/批量拼帧留业务侧,D9)」「image 不能为空」,`document` 为「document 必须是 bytes」「document 不能为空」)。
- **四处结果联合类型同步**: `_AttemptOutcome.result`(`ocr.py:91`)、`_invoke` 返回(`:486`)、`_emit` 的 `result`(`:582`)、`_summarize` 入参(`:625`)都加 `OcrDocumentTransportResult`;用于 `isinstance` 的 `OcrDocumentTransport` 与三种 TransportResult 从 `TYPE_CHECKING` 提到运行时导入。
- `_invoke` 的 document 分支调 `_document_transport().parse_document(document=payload, ...)`;`_summarize` 改 `isinstance` 三分支(document→`<document elements=N pages=M md_chars=K>`,text→截断正文,layout→原摘要);占位字面量不变 `<ocr:document image_bytes=N>`(`_request_for` 的 f-string 单点)。
- 遥测调用点 `_emit` 照旧传 `reasoning_applies=False`(单一 helper 铁律,不复制参数列表)。
- **测试(先红)**:
- `test_ocr_client.py`: `ScriptedOcrTransport` 增 `parse_document` 与脚本动作 `"document"`(并把现有「非 text 一律 layout」改为三分支);成功路径字段(元素/`markdown`/`usage(0,0)`/`call_stats`/gate 记成功);输入校验(`TypeError`/`ValueError` 与旧文案不变的回归);transport 缺 `parse_document` → `TypeError` 且**无遥测行、无统计**;`call_deadline_s` 非法值/继承值与 `parse_layout` 同行为;换源/退避/`ResultInvalid` 不计窗口(`gate.successes==[("m1", False)]`)、本地拒绝不记成功、`status_code=500` 的 `RequestRejectedError` 记成功不计窗口且不喂选源器;超时 Transient → 计失败 → 重试(文档路径的对照);429 → 照扣重试预算、熔断窗口不变;取消穿透且在飞探针归还。
- **无延迟判慢**: 扩展 `ClockAdvancingOcrTransport` 的 `"document"` 动作,source `timeout_s` 设小值,在成功尝试内把 `FakeClock` 推进远超它;断言成功、gate 为 CLOSED、`fails==0`。分别构造两种前置状态: 「CLOSED 但已有失败计数」与「OPEN 冷却后的半开探针」(门与 client 共用同一注入时钟,显式配置 `probe_ttl_s`/租约 TTL,避免把租约接管混进断言)。
- **持续慢速 → `CallDeadlineExceeded`**(写法固定,真实事件循环时间): 真实 `MonkeyOcrTransport` + `httpx.MockTransport`,POST 返回合法 `download_url`,GET 返回自定义 `httpx.AsyncByteStream`,每产出一个小块前 `await asyncio.sleep(0.01)`;`call_deadline_s=0.1`;经真实 `OcrClient` 调用,断言 `CallDeadlineExceeded`(不是 `TransientError`)、流已关闭、permit 释放、探针归还、熔断窗口不变,并用计数证明确实已进入多块下载。**局限写进测试 docstring**: MockTransport 不执行 httpcore 的逐次读超时,此用例证明整体期限而非 read timeout。
- **真实解析异常贯穿客户端**: 真实 `MonkeyOcrTransport` + MockTransport 返回含 `10**400` 整数 / 加密成员的 ZIP,经真实 `OcrClient`,断言 `ResultInvalidError`、**终态遥测行已写**、半开探针已归还、熔断窗口不变(不能只用桩直接抛预制 `ResultInvalidError`)。
- `test_telemetry.py`(其 `_StubOcrTransport` 同步增 `parse_document`): `operation` 参数化加 `parse_document`;文档行 `operation=="parse_document"`、messages 占位、response 摘要含 `md_chars` 且**不含正文**、`tenant_id`/`meta` 落库、记录行 **`reasoning_effort is None`**(`reasoning_applies=False` 是传给 emitter 的参数,落库字段是 `reasoning_effort`)、终态失败行类名前缀;`_summarize` 三类各一条;`text_cap` 对文档占位同样生效。
- **验证**: `pytest tests/unit/test_ocr_client.py tests/unit/test_telemetry.py tests/unit/test_ports.py -q --no-cov` 全绿;`make lint`;`radon cc src/polygateway/ocr.py -n C` 无新增输出。
- **提交**: `feat: add OcrClient.parse_document with full governance`。
### T7 注册面与对齐工具 — [ ] 一次提交
- **文件**: `tools/check_wiki_alignment.py`、`research-wiki/schemas/llm-calls.md`(operation 取值,第 35 行附近)、`research-wiki/metrics/ocr-call-success.md`、`research-wiki/metrics/call-telemetry-coverage.md`、`make wiki` 重建的 `research-wiki/{index.md,log.md,graph/edges.json}`。
- **行为**: `_SIGNATURE_TARGETS` 加 `("OcrClient.parse_document", OcrClient.parse_document)`;三份 wiki 页补 `parse_document`。
- **红绿证据**: 红 = 改动前执行 `conda run -n PolyGateway python -c "import runpy; ns = runpy.run_path('tools/check_wiki_alignment.py', run_name='x'); assert any(n == 'OcrClient.parse_document' for n, _ in ns['_SIGNATURE_TARGETS'])"` 以 `AssertionError` 退出非零;绿 = 改动后同命令退出 0。`make wiki-check` 若依赖外部 Gitea Wiki 仓库而不可达,如实记录「不可达,未执行」,**不编造通过**。
- **提交**: `docs: register parse_document in telemetry schema and alignment targets`。
### T8 活体集成用例(`slow`,opt-in) — [ ] 一次提交
- **文件**: `tests/integration/test_monkey_ocr_document_live.py`(**新文件**,模块级 `pytestmark = pytest.mark.slow`,**不**带图片语料的 `skipif`)。
- **行为**: 测试内手写最小合法 PDF 构造函数 `_minimal_pdf(text)`(5 个对象、**计算** xref 偏移、大号 Helvetica 文字,无第三方内容、无新依赖)。统一放在 `class TestLiveDocument`,三条用例,串行、单份小文档(服务与医院线共用,不压测):
1. `test_parse_document_returns_pages_and_text`: PDF 文字取 `POLYGATEWAY SYNTHETIC SAMPLE`;断言 ≥1 页、`page_sizes[0]` 均为正、`"SYNTHETIC"` 出现在 `markdown.upper()` 与某个元素 `text.upper()` 中(**正向断言**,空串不能通过)。
2. `test_recognize_text_on_pdf_is_not_the_hallucination`: 同一 PDF 的 `recognize_text`: `text.strip()` 非空且 `"single character"` 不在其中,并含 `"SYNTHETIC"`。
3. `test_corrupt_documents_do_not_open_the_breaker`: 以阈值 2 的环境,**连续两份**截断但保留 `%PDF-` 头的 PDF → 均 `RequestRejectedError`;随后有效 PDF 仍成功(若坏文档被误计失败,两次即达阈值,第三次会被熔断拒绝)。
- **红绿证据**: 本任务是**活体验收**,不存在「先红」;诚实记录为「无行为红灯,证据 = 单元层 T2/T4/T5 的红绿 + 本任务的活体输出」。手动命令选中全部三条: `pytest tests/integration/test_monkey_ocr_document_live.py -m slow -q --no-cov`,输出存 `tests/outputs/issue34/`(不提交);服务不可达时如实记录,**不 skip 掩盖**。默认套件下 `pytest tests/integration/test_monkey_ocr_document_live.py -q --no-cov` 应显示 3 deselected。
- **提交**: `test: add live PDF document parsing checks`。
### T9 文档与版本 — [ ] 一次提交(bump 不得裸发,CLAUDE.md §4.4.1)
- **性质**: 文档/版本任务,无行为红灯;证据 = `test_package` 的版本一致性用例 + 对版本串与方法名的 `grep` 输出。
- **文件与内容**:
- `pyproject.toml` 与 `src/polygateway/__init__.py` 的版本 → `1.3.11`。
- `CHANGELOG.md`: 新增「未发布」段(发布时定版): 新增公共面(`parse_document`/`OcrDocumentElement`/`OcrDocumentResult`/`OcrDocumentPort`/`OcrDocumentTransport`/`CallOperation "parse_document"`);**行为变更**: PDF 上传改标 `doc.pdf`(`recognize_text` 修复静默错误、`parse_layout(PDF)` 上传分支变更并写明已测[文本型、位图型]与未测[旋转页、批注页]范围);新路径确定性坏输入归「请求被拒」;**已知限制**: 服务端丢弃脚注/页眉页脚/页码、`markdown` 含悬空 `images/` 引用、多行/多 span/`inline_equation`/嵌套 caption 路径未经实测、`parse_document` 不接受魔数表之外的图片格式;关联 #34,后续 #35/#36。
- `README.md`: OCR 小节加 `parse_document` 用法与能力表一行、方法数、文档线配置示例;**设计 §7 的运维要点逐条落字**: ① 文档线独立 scope、`MAX_CONCURRENCY=2`、`RETRY__MAX_ATTEMPTS=2`、`TIMEOUT_S ≥ 最大页数×1.5s×余量(争用取 5 倍以上)`、`OCR_DOC__CALL_DEADLINE_S ≤ PGW_LEASE_TTL_S`(`timeout_s` 不是总耗时上界);② 重试放大(GET 失败会重新 POST 整份文档重算);③ 无页数预检,过滤归调用方;④ 服务端磁盘产物累积,需告知服务运维;⑤ 多 worker 共享并发上限需 `PGW_LIMITER_BACKEND=redis` 且同 scope;⑥ sync-only 后端的排队与对医院线的队头阻塞、须向运维确认 backend(启动日志 `async-capable`/`sync-only`);⑦ 服务丢弃脚注等;⑧ 租约守卫只比 1× `timeout_s` 的缺口与「守卫改造另行立项」。**安装命令的版本约束逐项核对**(CLAUDE.md §4.4.1 第 1 步)。
- `.env.example`: OCR 段加文档线独立 scope 示例(`OCR_DOC__MONKEY__1__*`、`OCR_DOC__CALL_DEADLINE_S` 等,键名以 `config.py` 为准)。
- `research-wiki/ARCHITECTURE.md`: §5.1 类型清单、§7.10 端口表加 `OcrDocumentPort`、D9 注记、**:597 的 operation 枚举加 `parse_document`、:433 的「四个公共方法」补新增入口**;`designs/2026-07-21-m3-ocr-design.md` §4 加勘误(「服务按内容处理」只验证图片,指向本设计 E3/E4/E10);`migrations/chsanalyzer.md` 加 CHSAnalyzer3 新消费路径与 H3 待确认项。
- `research-wiki/ROADMAP.md` 若有 OCR 状态行则同步。
- **提交**: `docs: document parse_document and bump version to 1.3.11`。
### T10 验证与审查(合并前硬门)— [ ] 验收任务,无独立红灯;审查修复另提
1. `make ci`(只读验证: `ruff format --check` + `ruff check` + `lint-imports` + 全套件)——输出重定向文件取退出码,**不接管道**。
2. 覆盖率: 新增/改动模块行覆盖率 ≥ 80%,未覆盖行逐一说明;对改动的三个模块运行 radon,**以实测为准**报告。
3. 派**全新上下文 verifier subagent**(`verification-before-completion`): 逐条对照设计 §4-§10 与本计划验收标准,只读核对测试证据(红灯日志存在且失败原因正确);任何声明须对应本会话工具输出。
4. `requesting-code-review` 整分支审查(含 Codex `--fresh --wait`);逐条核验后修订,不盲从。
5. 活体: T8 手动跑一次的输出;并用本会话在 `/tmp/claude-1000/-home-iomgaa-Projects-PolyGateway/a9dd153e-445b-42f7-9808-4ac914c3f176/scratchpad/ocr/` 里**本地存证**的样本(`x/`、`r/`、`t/`、`scanA/`,第三方内容,**不入库**)对 `parse_document_package` 做离线冒烟: 先检查目录存在,核对 `elements` 数量 = middle 的 `para_blocks` 总数(29/89/30/29)、`text` 与 `content_list` 反转义后一致、`markdown` 等于 `.md` 原文;**样本不存在则在报告中列为未验证项**,不得声称已验证。
6. 最终报告 ≤300 行,写入 `research-wiki/reviews/`;向人类汇报**未验证项**(多行/多 span、嵌套 caption、旋转/批注页差分、线上 backend、CHSAnalyzer3 延期语义、真实 httpcore read-timeout 行为)。
7. **停在此处,等待人类的发布指令**(合并 main、打 tag、构建、上传、验证已发布、建 Release、关闭 #34 逐步执行,CLAUDE.md §4.4.1)。
## 4. 自审清单
| 检查 | 结论 |
| --- | --- |
| 设计每条决策有对应任务 | Q1→T1/T6;Q2→T2/T5;Q3→T3;Q4→T4/T5;Q5 与 §7 运维要点→T6(测试)/T9(逐条落字);§5.1 契约→T3(参数化逐分支);§6 分类表→T4-T6;§8 旧行为→T2/T5/T3 回归断言;H5/H6 变更说明→T9 CHANGELOG |
| 跨任务签名一致 | `parse_document_package` 返回三元组(T3)被 T5 `to_thread` 消费;`rejected_markers`(T4)被 T5 透传;`_document_transport()`(T6)是唯一 isinstance 点;四处结果联合类型(T6)已逐处列出 |
| 无占位语 | 无 TBD/TODO;关键接口已在 §2 给出实际签名;时间/并发类测试的写法已固定 |
| 复杂度 | 只能「预计」;T3 已拆到 `_line_text/_span_text/_table_text/_page_index`;每次提交前 radon 实测 |
| 证据完整性 | 每个行为任务写明红灯预期;T7 有可执行红绿命令;T8-T10 如实声明无行为红灯 |
| 范围 | 不含 #35/#36 的改动;不含发布动作;不改 `parse_layout` 的校验口径与旧入口文案;不动既有 `test_monkey_ocr_live.py` |
## 5. 计划审查记录
Codex 一轮只读审查,结论「修订后进入执行」。逐条核验并全部采纳(第 1 条为我方计划自身的缺陷): ① T3 异常边界放错位置 → 移到最外层(已验证 `_parse_pages` 在 `_read_members` 之后执行);② T8 与既有集成测试文件冲突 → 改新文件(已验证默认收集 6 项旧用例);③ 三类时间/取消测试写法 → 已固定;④ T5 顺序 → 先错误状态后体积;⑤ T6 遥测字段名与联合类型 → 订正(已验证落库字段是 `reasoning_effort`);⑥ 覆盖缺口 → 补入 T3/T5/T9;⑦ 红绿证据 → 逐任务声明;⑧ T8 断言强度与 T10 样本前置 → 订正;⑨ 复杂度表述 → 改「预计」;⑩ 文件清单/文案兼容 → 订正。
@@ -0,0 +1,43 @@
九个审查点已全部核对完毕。以下为完整审查报告。
---
# 1.3.8 流式中段沉默分支:整分支合并前审查
- **结论先行:可合并(OK with notes)**。无阻断项;2 条建议级(P2)发现,均不阻塞合并。
- 范围:`main a28d944...HEAD ca8743b`(代码提交 901a193/53bcf43/e0b0be0/2ac05a2 + 文档提交)。审查方式:只读,未运行 git/pytest(审查纪律),一切裁决以当前工作区源码 + 测试 + 文档实读为证据;绿态证据引自 `findings/2026-09-11-streaming-stall-validation.md` 登记的命令输出。
## 逐点裁决表
| # | 审查点 | 裁决 | 关键证据 |
| --- | --- | --- | --- |
| 1 | 取消穿透 | **通过** | `retry.py:596-645` `_past_hedge_window` 两阶段均逐字套用"waiter.cancel() → await waiter → CancelledError 时 `cancelling()` 判外部取消穿透"既有范式;阶段 1(`:608-622`)、阶段 2 循环内(`:630-642`)、对冲在途(`_attempt_hedged` Phase 5 `:540-556` + `except BaseException` 收口 `:557-567` 逐字保留)三时点全部穿透。测试:`test_stall_hedge.py::test_external_cancel_during_stall_wait_penetrates`(阶段 2 内取消,无残留任务、permit 释放、`asyncio.all_tasks()` 前后相等)+ `test_hedge.py:501` 既有取消用例未动 |
| 2 | Event 并发纪律 | **通过** | 与计划 §3.6 伪代码逐点比对:①复检 `is_set()` 在 `primary.done()` 之后、触发之前(`retry.py:638`);②`is_set()`→`clear()` 紧邻无 await(`:638-639`);③对冲路恒 `content_event=None`(`:513` Phase 4);④每轮自建 waiter(`:630`);⑤等待者唯一(编排单点等待,waiter 进 finally 收口后才进下一轮)。唯一偏差:阶段 1 的 waiter 创建被提升到 `if` 分支外(`:608`),功能等价且更简洁,非问题 |
| 3 | 净口径换算唯一处 | **通过** | 全库 `int(.*stall` 仅 `retry.py:377-378` 两处相邻行(同一次换算);`net = max(0, wall_ms − (stall_ms or 0))` clamp 收敛在 `_record_winner_sample`(`:575-590`),单路(`_record_generation`)与对冲赢家(`_register_hedge`)共用。None 透传五路径逐一核实:非流式(`_complete_once` 永不置位、TransportResult 默认 None)、chunk<2(`_stall_from_chunk_times` 返回 `(None,None)`,`openai_compat.py:98-99`)、两败(`_register_hedge(winner_sink=None)` 不记 generation、stall 保持 None)、embedding/ocr(`record_generation` 不带 stall kw,embedding.py/ocr.py 零改动已 grep 证实) |
| 4 | 看门狗汇合 | **通过** | Phase 5"先了结的是失败则等另一路"(`retry.py:540-543`)逐字复用;看门狗掐死 → `StreamLivenessTimeout`→`TransientError`→`_attempt` 既有失败分支(`breaker.record_failure`/`attempt_fails`/遥测 error 行照常,`:415-437`)。测试 `test_stall_hedge.py::test_watchdog_kills_primary_hedge_leg_survives` 断言熔断失败样本 +1、主路 error 行携 TransientError 对象、副路正常行——N9 不对称(掐死喂熔断 vs 输家取消不喂)未改任一 |
| 5 | 配置守卫 | **通过** | `check_hedge_assembly` 返回 `tuple[float|None, float|None]`(`config.py:819/912`),两调用点(`config.py:391`、`client.py:255`)均解构;两阈值皆 None 才早退(`:855`,先于空源检查,B2 钉死落实);三守卫齐备:有效最小值 ValueError(`:885-894`,`inter_token_timeout_s or timeout_s` 回落与 `openai_compat.py` 看门狗装配同款)、deadline 冲突 ValueError(`:895-899`)、单源 warning(`:900-906`);B4 落 `_warn_hedge_keys_on_non_stream_scope` + Embedding/Ocr 两 `__post_init__`(`:950/:996`,含 1.3.7 AFTER_S 同病补齐);`STREAM` 保留段(`:57`);三层分发正确:`from_settings` 恒分发(`client.py:543`)、直构造 `None` 不 stomp、非 None 经 `dataclasses.replace` 触发 `__post_init__` 重跑校验(`client.py:267-270`)。测试 `test_config.py:1304-1436` 全覆盖 |
| 6 | 遥测链路 | **通过** | 协议 +2 参无默认值(`ports.py:343-344`,共 38 参,`test_ports.py:324` 钉住);`_AttemptUsage.of()` 不从 response 取(B2 裁决 b,`telemetry.py:219-223`);`emit_attempt` 显式转发、`emit_cache_hit`/`emit_terminal_failure` 显式 None(`:465-466/:515-516/:570-571`);`_record` +2 参透传(`:616-617/:686-687`);schema 五编辑点齐备且保序对齐(`schema.py:66-67/110-111/146-147/175-176/227-228`)物理列 39;DDL 幂等有测试(`test_telemetry.py:460-480` 二次初始化零 ALTER、`:485-503` manual 档点名两条新 ALTER);`test_hedge.py:145-148` RecordingEmitter 显式全签名 +2 kw(B1)已同步。附带:`sqlite.py:146` 计数已同步 38;`postgres.py:411` docstring 本就无字段计数文本,计划"计数同步"对它无对象,无残留"36"字样(全 src grep 证实) |
| 7 | 默认关闭回归 | **通过** | 入口门第一分支(两阈值皆 None)逐字走 1.3.7 单路且 `first_token_event=None, content_event=None`(`retry.py:285-295`);第二分支(仅 stall + 非流式)同形(`:297-307`);T1 阶段单路调用点恒传 None 已并入最终形态;`SourceConfig.stall_threshold_ms` 默认 1000 只影响计量(这是已批准的 P1 行为变更本体,CHANGELOG 行为变更段已披露,非回归);findings 登记批次 E 门:`1688 passed, 17 skipped` 且既有语义断言一行未改 |
| 8 | 反 gold-plating | **通过** | 全 src grep `1.3.8` 标记仅落在计划 §2.1 清单内 8 个文件(ports/types/config/client/retry/telemetry/schema/openai_compat);`errors.py`/`deadline.py`/`streaming.py`/`middleware/{ratelimit,breaker,structured,cache,admission}.py`/`embedding.py`/`ocr.py`/`tests/contracts/**` 零新增引用(仅既存 issue #8 stall-window 语境);无 `except Exception: pass`、无 `print()`、日志全 loguru、公共函数类型注解齐全;缓存指纹 `build_model_fingerprint` 只取 model/extra_body/thinking 三件套(`client.py:125-164`),`stall_threshold_ms` 不进指纹——改阈值不触发缓存冷启动,无意外副作用 |
| 9 | 测试质量 | **通过(带 1 条 P2)** | 批次 D 复检用例确实走到"waiter 正常完成 → is_set→clear→continue"路径(不是平静场景:chunk 压在 0.049s、阈值 0.05s,复检-继续被真实执行);对冲赢家净口径、看门狗记账、两败一次预算、取消穿透、对冲路恒 None 的断言强度与 `test_hedge.py` 同档(同一设施范式 + 逐次身份记录 + 任务泄漏断言)。`test_stall_hedge.py` 还主动补了计划外的 `test_single_leg_attempt_row_carries_stall`(T3 缺口钉:chat 成功行经 `_emit` 端到端落 stall 两值),属范围纪律内的测试加固,非 gold-plating |
## 发现列表
**阻断(B):无。**
**建议(N/P2):**
- **N1(P2)`retry.py:369-371` 注释阶段性残留**:`_attempt` 内 `content_event=content_event` 处注释仍写"本阶段(T1)所有调用点恒传 None = 不观测;T4 才在启用 stall 对冲的流式原路上接真事件"。T4 已落地,该缝现在转发的是调用方传入的真事件,"本阶段(T1)"措辞会让后来者误以为代码停在 T1 中间态。最小修:把"本阶段(T1)…T4 才…"改为终态表述("未启用 stall 对冲时调用方传 None;启用时流式原路携真事件,对冲路恒 None")。不影响行为,可合并后随手改。
- **N2(P2)复检用例时序余量仅 1ms**:`test_stall_hedge.py::test_stall_recheck_prevents_false_trigger` 用 `chunk_after=0.049s` 对阈值 `0.05s`,与本文件自定"容差 4–10×"纪律相逆(此处是反向边界,宽余量反而测不到复检路径,故取值本身可辩护);且真实 loop 钟下"chunk 与超时同一 pass 到达"不可确定性复现,该用例实际测到的是"超时前一刻到达 → 复检-继续",严格同 pass 竞态仍只剩代码走查兜底(已走查,正确)。风险是 CI 抖动下 sleep 晚醒 >1ms 会假触发使 `transport.calls` 多出 s2 而红。计划 §7 已预留缓解(调宽至 0.45× 阈值并记 findings),合并不阻塞,建议观察首轮 CI 表现。
## 残余风险与说明
- 本审查为只读静态审查 + 已登记绿态证据复核,未重跑测试;合并前按 CLAUDE.md §4.4.1 第 4 步仍需跑 slow 交集子集(Redis 时间语义变体 + `test_smoke_gateway.py`),`test_thinking_live.py` 矩阵按 2026-09-10 决议不跑(复用 1.3.7 证据)。
- `_past_hedge_window` 复检窗口只能收窄不能归零(计划 §8 已诚实标注),属已批准残留。
- 对冲取消与外部取消同时到达时可能误贴 `hedge_cancelled` 标签,`retry.py:418-423` 注释已声明记账方向一致、属已批准可接受残留。
**Merge verdict: OK with notes**(N1/N2 不阻塞;建议合并后小修 N1 注释)。
---
**结论:可合并(OK with notes)。发现计数:阻断 0 / 建议 2(N1 `retry.py:369-371` 注释阶段性残留;N2 `test_stall_hedge.py` 复检用例 1ms 时序余量偏紧)。**
@@ -0,0 +1,94 @@
# 1.3.8 streaming-stall 合并前独立验证报告(verifier,全新上下文)
- 仓库: `/home/iomgaa/Projects/PolyGateway`;分支 `feature/1.3.8-streaming-stall`
- HEAD 核实: `.git/HEAD` → `ref: refs/heads/feature/1.3.8-streaming-stall`;`.git/refs/heads/feature/1.3.8-streaming-stall` = `8489b8ea50308ca75e0876d293fef7feddf9b086`(即 8489b8e)✓
- **能力声明(重要)**: 本 verifier 会话只有 read/grep/find/ls 四类只读工具,**无 shell 执行能力**(角色纪律: 禁止 shell、禁止写文件)。因此动作 b(套件重跑)、c 的重跑部分、d(行为抽测)、e(`make lint` 重跑)**无法由我本人执行**;我已亲自读取实现方落盘的全部日志与全部相关源码做静态+证据核实,运行时必须由 supervisor 按文末清单补跑后方可合并。pi-lens/LSP 诊断未作为任何判据(指令 g)。
## 结论
**有条件通过(OK with notes)**: T1–T5 全部声明在源码与落盘证据两个维度均核实为真;红绿证据链真实(红日志含真实失败因、绿日志计数单调递增且与提交序一致)。**两个证据缺口**(见发现 P2-1/P2-2)必须补跑: integration 套件无任何落盘证据、且本会话无法亲跑任何运行时命令。
## 逐声明验证证据
### a) 提交序列(静态替代 `git log`)
`.git/logs/HEAD` reflog 末端逐行亲读,分支自 main `a28d944`(1.3.7)拉出后序列:
| 提交 | 主题 | 对应 |
| --- | --- | --- |
| 03cbf4c / 97de410 / 929d60e / 6f3a620 | docs: 设计 + 评审勘误 + 计划 | 文档前置 |
| 901a193 | feat: content event + stall accounting to transport port | T1 |
| 53bcf43 | feat: generation_ms 净口径重定义 | T2 |
| e0b0be0 | feat: stall_ms/stall_count 遥测列 | T3 |
| 2ac05a2 | feat: opt-in 中段沉默对冲 | T4 |
| ca8743b | docs: 净口径与中段对冲文档 | 文档 |
| 8489b8e(HEAD) | docs: resolve branch review note(content_event 接线终态注释) | 文档 |
与声明序列(901a193/53bcf43/e0b0be0/2ac05a2/ca8743b/8489b8e + 文档提交)**逐项一致** ✓
### T1 端口契约 —— 成立
- `src/polygateway/ports.py` `Transport.complete`: kw-only 参数列含 `content_event: asyncio.Event | None`,**无默认值**(与 `first_token_event` 同一约定,docstring 明示 1.3.8 #27/#28)✓
- `src/polygateway/types.py:619-628` `TransportResult.stall_ms: float|None=None` / `stall_count`,docstring 钉死 None(无从观测)vs 0.0(观测过零沉默)区分、尾部不计 ✓
- `types.py:683-689` `SourceConfig.stall_threshold_ms: int = 1000`,docstring 明示"严格大于才计";`types.py:734-743` `_validate_stall_threshold` 拒 bool/非 int/≤0 ✓
- `transports/openai_compat.py:88-109` 纯函数 `_stall_from_chunk_times`: `len<2 → (None,None)`(L98-99);`gap_ms > threshold_ms` 严格大于(L106);无超阈 → `(0.0,0.0)`(L109);只遍历相邻对(zip times/times[1:])→ 尾部天然不计 ✓
- `openai_compat.py:608-618` 流式循环: 每增量 chunk `chunk_times.append(now)` + `content_event.set()`;L634 由 `_stall_from_chunk_times(chunk_times, source.stall_threshold_ms)` 派生两值进 TransportResult(L643-644)✓
- 非流式 `_complete_once`(L698-769): 收两事件永不置位,TransportResult 不传 stall 字段(默认 None)✓
### T2 净口径 —— 成立
- `types.py:331-336` `CallStats.stall_ms/stall_count: int|None = None`;`types.py:322-328` `generation_ms` docstring 重定义为净口径(含 clamp、还原公式、排除条款)✓
- 唯一换算点 `middleware/retry.py:671-681` `_record_winner_sample`: `net_ms = max(0, wall_ms - (stall_ms or 0))`(clamp 到 0 ✓);None 透传不退减 ✓
- 取整唯一处 `retry.py:376-378`(墙钟/stall 同点 int 化,墙钟=generation_ms+stall_ms 可还原成立)✓
- embedding/ocr 路径:`record_generation` 的 stall 参数默认 None(`types.py:386-387`),既有调用点不传 → 恒 None ✓
### T3 遥测 —— 成立
- `telemetry/schema.py` `COLUMNS` 亲数 **38 项**(末两项 stall_ms/stall_count,L227-228);`SQLITE_DDL`(L66-67)与 `PG_DDL`(L110-111)均含两列;`SQLITE_BACKFILL`(L146-147)与 `_PG_BACKFILL_DECLS`(L175-176)列序对齐 ✓
- `ports.py` `TelemetryRecorder.record_llm_call` 38 个 kw 参数(末两个 stall_ms/stall_count,无默认值)✓
- 迁移幂等: SQLite `sqlite.py:125-143` 先 `PRAGMA table_info` 探测、`column in existing: continue` 跳过、duplicate column 竞态视为成功;PG `postgres.py:352-369` 同款先探测后 ALTER ✓
- 取消输家行 stall 恒 None: `retry.py` CancelledError 分支 `self._emit(..., error=label)` 不传 stall → `_emit` 默认 None(L774-775);emitter `emit_cache_hit`/`emit_terminal_failure` 显式 None(`middleware/telemetry.py:515-516,570-571`)✓
### T4 中段对冲 —— 成立
- `config.py:201` `hedge_stall_after_s: float|None = None`(默认关闭);`_load_hedge`(L801-807)读 `{SCOPE}__HEDGE__STALL_AFTER_S`;`_load_stream`(L756-774)读 `{SCOPE}__STREAM__STALL_THRESHOLD_MS`;`STREAM` 已进 `_RESERVED_SEGMENTS`(L57)✓
- 三守卫唯一事实源 `check_hedge_assembly`(L810-912): stall ≥ 有效最小值(`inter_token_timeout_s or timeout_s` 回落)→ ValueError(L888-894);stall ≥ call_deadline_s → ValueError(L895-899);单源 warning(L900-906);两阈值皆 None 才早退(审查 B2)✓;`GatewaySettings.__post_init__` 与 `GatewayClient.__init__` 共用同一份(config.py:391-401 / client.py:255-263)✓
- Embedding/OCR scope 对冲键 warning: `_warn_hedge_keys_on_non_stream_scope`(config.py:915-927),被 EmbeddingSettings(L950)与 OcrSettings(L996)`__post_init__` 调用 ✓
- 编排 `retry.py`: 仅 stall+非流式 → 逐字单路(L290-301);两阶段触发窗 `_past_hedge_window`(L591-652):阶段 1 首 token 前走 AFTER_S、仅 stall 时须等首 token(防 pre-TTFT 误吞);阶段 2 循环 `asyncio.wait(timeout=stall_after_s, FIRST_COMPLETED)` + N3① 超时后复检 `content_event.is_set()` 防误触发 + N3② check→clear 无 await;触发后 FIRST_COMPLETED 竞速(L538-539);对冲路恒 `content_event=None`(L533);输家 `cancel()` 前置 `_HEDGE_LOSER_ATTR` 标记(L568-571),_attempt 取消分支据此标 `hedge_cancelled` 且**不 record_failure**(不喂熔断,L414-429)✓
- 三层可调: env 键 → `GatewaySettings.stall_threshold_ms`(config.py:206)→ `from_settings` 恒分发(client.py:543);直传 `GatewayClient(stall_threshold_ms=...)` 经 `dataclasses.replace` 分发且重跑 `__post_init__` 校验(client.py:264-270)✓
### T5 默认关闭回归 —— 静态成立 + 落盘证据
- 代码门: `retry.py:279` `if self._hedge_after_s is None and self._stall_after_s is None:` → 单路 `first_token_event=None, content_event=None`(注释明示"逐字 1.3.7 单路路径,默认关闭回归门据此成立")✓
- 落盘: `tests/outputs/138/t4/green-unit.log` 末行 `1688 passed, 17 skipped in 54.90s`(与声明 5 的 1688 一致)✓
### c) 红绿证据真实性抽查 —— 真实
- t1 red: ImportError `_stall_from_chunk_times`(功能不存在); green: 260 passed ✓
- t2 red: 6 failed(CallStats 无 stall_ms 属性、record_generation 拒 stall kw、净口径 5000≠1800、clamp 5000≠0 等精确断言); green: batch 40 passed / unit 1608 / unit+contracts 1669 ✓
- t3 red: 14 failed(COLUMNS 缺两列、`$` 占位符 36≠38、真实 SQLite `no such column: stall_ms`、幂等补列断言红); green: batch 238 / unit 1673 ✓
- t4 red: 16 failed(RetryMW 拒 `stall_after_s` kw、settings 无 `hedge_stall_after_s`、STREAM 段造出假源 `stream_1`、三守卫 DID NOT RAISE);green: batch D 169 passed、contracts 61 passed ✓
- t4/t5 lint: ruff `All checks passed!` + import-linter `Contracts: 1 kept, 0 broken` ✓
- 计数链单调: 1602(T1)→1608(T2)→1673(T3)→1688(T4),与逐批新增用例一致;红日志失败因均为"功能尚不存在"类,非环境噪声 ✓
### f) 文档一致性 —— 一致
- CHANGELOG 未发布段: 行为变更(口径重定义+还原公式+None 条款+取消输家恒 NULL)、遥测两列(36→38,旧行不回填)、中段对冲(默认关闭/独立生效/三守卫/19.0% 触发率诚实标注)✓
- README §"1.3.8 生成时间口径与中段沉默对冲"(L115-140): `generation_ms` 定义句"净生成时间 = 赢家墙钟 − Σ(> STALL_THRESHOLD_MS 的相邻增量 chunk 沉默)"与代码 `max(0, wall-stall)` 一致;能力表"遥测必录 38 字段"(L26)、逻辑调用统计段(L27)、配置表两键(L137-138)与代码一致 ✓
- `.env.example` L83-89: `STREAM__STALL_THRESHOLD_MS=1000` 与 `HEDGE__STALL_AFTER_S` 两键注释与守卫文案一致 ✓
- ARCHITECTURE.md §7.2(L504/506)净口径+两阶段触发窗登记段、§7.5(L569/571)38 字段与 stall 两列登记段,与代码行为一致 ✓
## 发现列表
- **P2-1(证据缺口,合并前必须补)**: `tests/outputs/138/` 下**无任何 tests/integration 运行证据**;动作 b 要求 `pytest tests/unit tests/contracts tests/integration`。本 diff 直接触及 PG 遥测列(`tests/integration/test_postgres_telemetry.py` 的 `_EXPECTED_COLUMNS` 已含 stall 两列、占位符/列序断言只在真实 PG 上有效)与治理栈,integration 未跑 = PG 侧补列/写入路径只有静态与 SQLite 证据。
- **P2-2(流程限制)**: 本 verifier 无 shell,动作 b/c 重跑/d/e 全部无法亲跑;现结论中"套件绿"的依据是我亲读的实现方落盘日志(文件真实、失败因真实、计数链自洽),非我本人执行。合并前须由 supervisor 补跑下方命令清单,输出落 `tests/outputs/138/verify/`。
## supervisor 必须执行的命令(全部 `conda run -n PolyGateway`,禁管道,落 `tests/outputs/138/verify/`)
1. `git log --oneline main..HEAD`(核对序列;我已于 reflog 静态核实,仍建议权威跑一次)
2. `pytest tests/unit tests/contracts tests/integration -q`(记 exit code 与计数;unit 期望 1688,contracts 期望 61)
3. 关键用例重跑(现绿确认): `pytest tests/unit/test_retry.py::TestGenerationMs tests/unit/test_stall_hedge.py::TestStallHedgeTrigger::test_stall_recheck_prevents_false_trigger tests/unit/test_stall_hedge.py::TestStallHedgeWatchdogAndFailure::test_watchdog_kills_primary_hedge_leg_survives tests/unit/test_telemetry.py::TestStallColumns -q`
4. 行为抽测 d①-③(流式假传输端到端 stall/净 generation_ms;仅 STALL_AFTER_S 下沉默触发对冲且 hedge_won;两键不设时 hedges=0/hedge_won=False)
5. `make lint`
**合并判定: OK with notes** —— 静态与落盘证据全绿;P2-1/P2-2 补跑通过后即可合并,若补跑出现任何红则转 BLOCK。
@@ -0,0 +1,91 @@
核实完毕。所有关键引用已对照源码验证。以下为审查报告全文。
---
# 流式中段沉默设计(1.3.8)独立审查报告
- 审查对象: `designs/2026-09-11-streaming-stall-design.md`(已批准,1B' + 2B)
- 对照: `findings/2026-09-11-streaming-stall-hedging-research.md`、`designs/2026-09-10-24-hedged-requests-design.md`、main `a28d944` 源码(retry.py / openai_compat.py / streaming.py / types.py / ports.py / telemetry/schema.py / middleware/telemetry.py / config.py / client.py 全部实读)
- 立场: 不推翻人类 §9 五项拍板;只找设计与实现层面的漏洞
## 结论: **有条件实施**
总体架构判断正确: 计量与触发共用同一观测原语、复用 1.3.7 准入/记账/取消语义、遥测迁移机制现成,均无方向性问题。但有 **3 处阻断级设计缺口**(chunk 定义未钉死、遥测透传链路改动面遗漏、独立生效的编排入口遗漏)必须在实施前以设计补遗钉死;另有 9 条建议级修正。均为"补钉"而非"返工"。
## 九条审查角度逐条裁决
| # | 角度 | 裁决 | 依据(源码实证) |
| --- | --- | --- | --- |
| 1 | 沉默计时边界 | **存疑**(N1) | 尾部(最后内容 chunk→[DONE]/usage 帧/连接收尾)不是"相邻 chunk 间隔",不会被减,但仍留在墙钟基底里 → 净值**含尾部**;调研 §B3 的先例(AIPerf 剔除 [DONE]、pi-tps 止于最后 message_update)是"尾部不计",与设计的墙钟基底口径不同,设计未察觉也未声明。TTFT 同理留在净值内(人类原话"减去中段沉默"字面如此,可接受,但须明写)。应然口径: 显式声明"净值 = 墙钟 − 中段沉默,仍含 TTFT 与尾部",README 口径表同步 |
| 2 | 非流式与输家口径 | **成立**(建议 N2) | 排除条款(非流式/chunk<2 → None 且不退减)已明写,双口径是设计内选择;建议补一句"非流式 net≈wall(无中段沉默概念),下游预算公式以 `stall_ms is None` 分支"。输家排除由 `_register_hedge` 只记赢家 sink 的机制保证(retry.py `_register_hedge`)✓;但"输家数据留在其 attempt 遥测行可 join 还原"对**被取消**的输家不成立(无 TransportResult,行是 error 行)→ 见 B2 |
| 3 | P2 与看门狗竞态 | **成立**(措辞修正 N9) | 看门狗掐死抛 `StreamLivenessTimeout`(streaming.py:24-50),在 transport 边界翻译为 `TransientError ... from exc`(openai_compat.py:476-477),**不是** CancelledError。编排层**无需区分**两种主路之死: `_attempt_hedged` Phase 5 的"先了结的是失败则等另一路"分支对任何 `_Failed` 一律等副路,两败走 `_combine_failures`(retry.py:171-176)计一次预算——现有机制完整表达设计语义 ✓。但设计"主路之死不算失败"措辞不精确: attempt 级记账照常(breaker `record_failure("timeout")`、`attempt_fails`、`reasons`、遥测 error 行),"不算失败"的只是本轮超级尝试 |
| 4 | 触发信号并发安全 | **存疑**(N3) | `asyncio.Event` 在单循环内 check→clear 之间无 await 即无交错,但**超时到期后触发对冲前必须复检 `is_set()`**——否则"chunk 与超时同一事件循环 pass 到达"会误触发。1.3.7 的 `_past_hedge_window` 是一次性等待(retry.py:542-568),stall 需要**循环**等-清-重等,设计只写"等该事件超时",未钉循环模式。等待者只有一个(编排),对冲路恒传 None 是 1.3.7 约定(retry.py `_attempt_hedged` Phase 4),但设计未声明该约定**延伸到新的 chunk Event 参数** |
| 5 | 取消穿透 | **成立**(并入 N3) | 外部取消到达 → `_attempt_hedged` 的 `except BaseException` 两任务同消、尽力收口、原异常上抛,与 1.3.7 逐字一致 ✓;stall 等待循环每轮迭代自建 waiter,须逐字套用 `_past_hedge_window` 的 waiter 收口 + `cancelling()` 穿透纪律(retry.py:556-568),设计补一句即可 |
| 6 | 计量正确性 | **存疑**(N4) | 分层可行: 沉默在 transport 由 chunk 时间戳序列算出(openai_compat.py:580-590 循环邻域),净值在 retry 由"墙钟−stall"得。但**两处的钟不同源**: retry 用注入钟 `self._now`(retry.py:332/346,client.py:242 默认 `time.monotonic`),transport 硬编码 `time.monotonic()`(openai_compat.py:562/582)且 transport 不接收注入钟(client.py:508 构造只传 registry/capabilities)。生产同为 monotonic 无事;测试注入 FakeClock 时净值可为负/失真。赢家 sink 与 1.3.7 `generation_sink` 机制协调成立(改记净值即可)✓ |
| 7 | 遥测 DDL 迁移 | **成立**(编辑点清单见 N7) | schema.py 有成熟迁移机制: `SQLITE_BACKFILL`/`_PG_BACKFILL_DECLS` 保序追加、recorder "先探测后 ALTER"(auto 档)、`telemetry_schema_sql()` 给 manual 档同源 SQL。设计引用的迁移纪律(#12/#13/#15)与现状吻合 ✓。"遥测 DB 从未存过 generation_ms"经核实属实(COLUMNS 36 项无此字段)✓ |
| 8 | 配置守卫完备性 | **存疑**(N5) | 三段键解析**无冲突**: `_load_sources` 以 `len(parts) != 4` 跳过(config.py:431),`STREAM` 进 `_RESERVED_SEGMENTS`(config.py:57)防 4 段 `{SCOPE}__STREAM__{N}__*` 撞 provider 段,与 HEDGE 先例同型 ✓。但守卫有缺口: ①设计写 `stall_after_s ≥ inter_token_timeout_s`,未写**有效值回落**——运行时是 `inter_token_timeout_s or timeout_s`(openai_compat.py:573),`inter_token` 未设时守卫对象应是 `timeout_s`;②严重度与 1.3.7 不一致: 同类"永不触发"条件 `hedge_after_s ≥ min(timeout_s)` 是 **ValueError**(config.py:820-824),而 stall 场景非流式无残留收益(1.3.7 ttft warning 留有非流式收益,stall 没有),只给 warning 论证不足;③`stall_after_s ≥ call_deadline_s` 的 ValueError"沿用 1.3.7"须点名落进 `check_hedge_assembly`(config.py:772)同函数 |
| 9 | 内部一致性 | **存疑**(N7) | 无硬矛盾,但有三处错位: ①§4.2 改动面写 `config.py:46 邻域`——:46 是 `_SOURCE_FIELDS` 表(per-source 4 段键注册表),而 §6 明写 `{SCOPE}__STREAM__STALL_THRESHOLD_MS` 是 **3 段 scope 级键**,两处矛盾,正确先例是 `_load_hedge`(config.py:745-769)同款 loader + `GatewaySettings` 字段;②§4.2 写 `types.py:417-418 邻域(CallStats 增两字段)`——CallStats 在 types.py:296-324,417-418 邻域是 LLMResponse;③§5.2 写端口加参"须进批准项",但 §9 五项无对应条目(整体批准可视为覆盖,但设计自拟的程序未闭环) |
## 发现列表
### 阻断级(实施前必须以设计补遗钉死)
**B1 | 设计 §3/§4.2/§5.2 | "内容 chunk"定义未钉死,reasoning 增量归属不明**
- 问题: 源码现状是 `_iter_sse_deltas` 对 `content` 与 `reasoning_content` **都**产出(openai_compat.py:65-85),看门狗与 `first_token_event` 都把思考增量当存活证据(openai_compat.py:585-588 注释"思考增量同样是存活证据,与看门狗活性口径一致")。若按"内容 chunk"字面只计 `is_content=True` 的增量,则 reasoning 模型长 thinking 阶段(只流 reasoning_content)会被误计为沉默: P1 的 `stall_ms` 虚高、净口径失真;P2 在正常推理中误触发对冲。本库 reasoning 是一等特性,这不是边缘情形。
- 修法: §3 明写"chunk = 任何增量产出(content 或 reasoning_content),与看门狗/首 token 活性口径一致;仅空 chunk/usage 帧/SSE keepalive 不计时"。排除条款的"内容 chunk <2"同步按此口径。
**B2 | 设计 §4.2 改动面/§7 契约表 | 遥测两列的透传链路改动面遗漏,且"输家行可 join"表述不成立**
- 问题: attempt 行的用量字段由 emitter `_AttemptUsage.of(response)` 从 **LLMResponse** 读取(middleware/telemetry.py:216-236)。stall 两列要进 attempt 行,必须二选一: (a) LLMResponse 增 `stall_ms/stall_count`(公共类型,只增带默认值合法,但**改动面与 §7 契约表均未列**)+ `_build_response` 搬运;或 (b) `_emit` 显式传 TransportResult 两值。同时 `ports.py` 的 `TelemetryRecorder.record_llm_call` 签名必须加两参——这是**第三个端口签名变更**,§7 只列了 `Transport.complete` 与 `TransportResult`;emitter 的装配闸 `signature.bind` 以该协议签名为判据(telemetry.py:338),漏改协议直接装配期炸。另外 §4.2"输家数据留在其 attempt 遥测行可 join 还原"对**被取消**的输家不成立: 取消的输家无 TransportResult,其行是 `hedge_cancelled` error 行,stall 恒 None;只有"竞速落选但已完成"的输家才有数据。
- 修法: §4.2/§7 补全链路(选定 (a) 或 (b);ports.py recorder 签名 + emitter + 两 recorder 实现 + schema.py 四处编辑点);修正输家表述为"已完成输家的沉默在其 attempt 行;被取消输家无沉默数据(传输未返回),其存在性由 `hedge_cancelled` 行表达"。
**B3 | 设计 §5.2 改动面 | "独立于 AFTER_S 生效"所需的编排入口改动遗漏**
- 问题: 现状 `RetryMW.__call__` 仅在 `self._hedge_after_s is not None` 时进对冲路径(retry.py `__call__` 分支)。§9-④ 拍板独立生效意味着 `hedge_after_s=None + stall_after_s=25` 必须也能进对冲路径——`__call__` 入口门须改为"两阈值任一已设且(对 stall)请求为流式"。同时: ①非流式 + 仅 stall_after_s → 无中段沉默概念,应逐字走单路(等价未配置),设计未声明;②`_past_hedge_window` 须从一次性等待重构为两阶段(首 token 前 AFTER_S 仅当已设 / 首 token 后 stall 循环),改动面只写"扩为"偏轻;③纯非流式部署设了 stall_after_s 是否给装配 warning,设计未涉及。
- 修法: §5.2 改动面补 `__call__` 入口门与非流式退化声明;§6 配置表补"stall_after_s 仅对流式生效,纯非流式 scope 设置时 warning(或静默,须拍板一句)"。
### 建议级
**N1 | §3/§4.2 | 沉默计时边界未明写(TTFT 与尾部归属)**
- 尾部(末 chunk→[DONE]/usage 帧)不是相邻间隔、不会被减,但留在墙钟基底 → 净值含尾部;TTFT 同理。调研 §B3 先例是"流终止信号后不计",与墙钟基底口径不同。修法: 显式声明"净值 = 墙钟 − 中段沉默,仍含 TTFT 与尾部;尾部归属活跃侧,与 pi-tps'止于最后更新'的差异是有意的(墙钟基底),尾部通常 ≈0";README 口径表同步。
**N2 | §4.2 排除条款 | 非流式双口径补一句显式声明**
- "非流式场景 net≈wall(无中段沉默概念),下游预算公式以 `stall_ms is None` 为分支条件"。
**N3 | §5.2 触发机制 | stall Event 循环的并发与取消纪律未钉**
- ①超时到期后触发对冲前复检 `is_set()`;②check→clear 之间不得有 await;③对冲路 chunk Event 恒 None(1.3.7 首 token 约定延伸);④每轮 waiter 收口 + `cancelling()` 穿透逐字套 `_past_hedge_window`(retry.py:556-568);⑤等待者唯一(编排),Event 无需多等待者语义。或改用"最后 chunk 时间戳 + 计算剩余时长"模式免 clear 竞态——二选一,设计钉一种。
**N4 | §4.2 改动面 | 钟同源纪律与负值防御**
- retry 墙钟(注入 `now`)与 transport 沉默钟(硬编码 `time.monotonic()`)生产同源、测试分叉。修法: 设计声明"沉默序列与墙钟必须同为 monotonic 语义;`net = max(0, wall − stall)` clamp;FakeClock 测试用假 transport 自控 stall 值,真实 transport 测试不得注入假钟"。
**N5 | §5.2/§6 | 守卫完备性三缺口**
- ①`stall_after_s ≥ inter_token_timeout_s` 须用有效值 `inter_token_timeout_s or timeout_s`(openai_compat.py:573 回落同款);②"永不触发"严重度对照 1.3.7 的 ValueError(config.py:820-824)需论证为何只 warning(stall 无非流式残留收益,升 ValueError 的论据其实更强);③`stall_after_s ≥ call_deadline_s` ValueError 点名落进 `check_hedge_assembly`。
**N6 | §4.2 `stall_count` 定义 | "连续停顿合并一次"在 gap 口径下无所指**
- 间隔口径里任意两个超阈值 gap 之间至少隔一个 chunk,不存在可合并的"连续停顿";该子句移植自 pi-tps 的事件流语义,在本口径下无操作定义。修法: 直接定义"`stall_count` = 超阈值相邻间隔个数",删合并子句。
**N7 | §4.2/§5.2/§6/§7 | 引用勘误与编辑点清单**
- `types.py:417-418` 实为 LLMResponse 邻域(max_inter_token_ms 在 :406),CallStats 在 :296-324;`config.py:401-407` 实为 :427-431;`config.py:46` 是 `_SOURCE_FIELDS`(与 §6 三段键矛盾,见裁决 9);`openai_compat.py:605` 是 max_inter_token_ms 装配处,max_gap 循环在 :580-590;`schema.py:43/85` 指向 ttft_ms 行,实际编辑点 = DDL 末尾(created_at 之后,schema.py 头注释纪律)+ `SQLITE_BACKFILL`/`_PG_BACKFILL_DECLS` 保序对齐 + `COLUMNS` + 列数断言测试 + `telemetry_schema_sql` 同源再生成;§5.2"须进批准项"在 §9 无对应条目,补一条批准记录或删该句。
**N8 | §4.2 vs §7 | 类型换算点未钉**
- TransportResult `stall_ms: float | None` vs CallStats `int | None`: 钉一句换算顺序(建议 retry 层 `int(wall_ms) − int(stall_ms)` 或先减后取整,二选一),避免各实现 ±1ms 漂移。
**N9 | §5.2 与看门狗的交互 | 措辞修正 + 一句哲学论证**
- "主路之死不算失败"改为"本轮超级尝试不因主路之死而败(等副路);attempt 级记账(breaker failure/`attempt_fails`/遥测 error 行)照常"。并补一句论证: 看门狗掐死喂熔断失败 vs 对冲输家取消不喂熔断,同为源沉默事件却不同处置——前者是既有看门狗语义(1.3.6 起),后者是 1.3.7 已批准语义,本设计不改任一,但运维侧"挂起率"统计须知道这一不对称。
## 核实记录(引用抽查,全部实读源码)
| 设计引用 | 实况 | 结论 |
| --- | --- | --- |
| retry.py:542-562 `_past_hedge_window` | :542 起,返回式逐字一致 | ✓ |
| retry.py:~335/~346 计时点 | :332/:346 | ✓(邻域) |
| openai_compat.py:573 回落 | :573 逐字一致 | ✓ |
| openai_compat.py:605 max_gap | :580-590 循环,:605 装配 | 偏位(N7) |
| types.py:417-418 | CallStats 在 :296-324 | 错位(N7) |
| config.py:46/57/401-407 | :46 ✓ :57 ✓ :401-407 实为 :427-431 | 部分偏位 |
| 遥测无 generation_ms 列 | COLUMNS 36 项核实 | ✓ |
| 看门狗抛 TransientError | streaming.py + openai_compat.py:476 | ✓ |
---
**结论行**: 有条件实施——3 条阻断级(B1 chunk 定义、B2 遥测透传链路、B3 编排入口)+ 9 条建议级(N1-N9),均为设计补遗级修正,无需推翻 §9 拍板。
**发现计数**: B1-B3(阻断级 3 条),N1-N9(建议级 9 条)。
@@ -0,0 +1,37 @@
核查完毕,所有证据齐备。撰写审查报告:
## Review
**结论:有条件执行** — 计划整体忠实于已批准设计(含 B1-B4/N1-N9),同步清单两条 grep 重跑基本属实,TDD 红绿逻辑与编排伪代码经源码逐行验证成立;但有 **2 处计划文本级硬伤**(按字面执行必在 T3/T4 验收门炸出计划声称"零改动/不改断言"的返工)需先修计划,另 5 处建议项。无设计级返工、无人类门事项。
### 8 条核查逐条裁决
**1. 同步清单真实性 — 基本属实,一处虚列。** 重跑两条 grep:§2.2 的 11 处 `def complete` + 11 处调用点全部命中且行号准确(含 test_retry.py:112 第二 fake 与 :123 转发、e2e/conftest.py:294/:306 转发、test_live_evidence.py:280/:1270、test_usage_source_domain.py:142);§2.3 的 13 处 `**fields` fake 计数精确、3 处负例(:3327/:3349/:3354 均为故意残缺显式签名)、2 处 `**fields` recorder(sqlite.py:145/postgres.py:411)、_DummyRecorder(test_ports.py:102)全部属实。**虚列**:§2.3 #8 声称 test_hedge.py:122"零改动"不成立(显式全签名 fake,见 B1);test_backpressure.py:233 是 `*args, **kwargs` 才真零改动。另漏列两处硬编码计数断言(见 N1)。
**2. 签名与行号 — 抽查 14 处,绝大多数精确。** 精确命中:ports.py:56/:306(36 参实测)、types.py:296/:328/:560/:581/:590/:633/:674、openai_compat.py:427/:553/:560/:659/:666(chunk 循环 :581-592、装配 :598-616)、retry.py:275/:314/:344/:346/:436/:484/:533-562/:574-578/:580-594、config.py:57/:195/:745-769/:772-849/:865/:904、client.py:240/:252/:280/:485、middleware/telemetry.py:198/:396/:449/:501/:556/:618、schema.py 五编辑点。**漂移**:`_attempt_hedged` 计划 :371-530、实际 **:415-531**;另有 5 处 ±1-6 小漂移(入口门 :268-279、from_env :394-424、_emit :668-700、_complete_once :659-705、_validate_hedge :375-392)。具名接缝均无歧义,不阻断(见 N2)。
**3. TDD 红绿逻辑 — 四批次全部成立。** 批次 A(端口未加参→TypeError、纯函数不存在→ImportError、字段不存在→TypeError)、批次 B(字段缺失→AttributeError、仍得墙钟值→断言失败)、批次 C(冻结清单先扩→列数/列序断言失败,已验证 `_EXPECTED_COLUMNS` 派生机制;协议未加参而 _DummyRecorder 先加→装配闸 ValueError)、批次 D(kw 不存在→TypeError、`hedges==0`)均为真红,无伪 TDD。措辞瑕疵:批次 D "键未识 ValueError" 实为 AttributeError/TypeError(3 段键被 config.py:431 静默跳过),红仍成立(见 N3)。
**4. 编排语义 — 并发正确性验证通过,与设计 §5.2 一致。** Event 复检防同 pass 误触发 ✓;check→clear 之间同步无 await(单线程循环下无交错)✓;每轮自建 waiter + finally 收口 + `cancelling()` 穿透逐字套用 :544-561 既有范式 ✓;Event 状态持久无丢失唤醒 ✓;残留竞速窗口已诚实标注(设计 N3① 批准形态)。仅 stall 时"阶段 1 无 timeout 等首 token→再进阶段 2"与设计 §5.2/B3 逐字一致——**pre-TTFT 沉默不被 stall 窗误吞正是设计意图**,TTFT 长等待由 ttft 看门狗治理 ✓。入口门三分支自洽(仅 stall+非流式→单路;AFTER_S+非流式→对冲路,事件退化为纯时间阈值,同 1.3.7)✓。**主路被看门狗掐死**:TransientError 在 `_attempt` except 分支转为 `_Failed` 返回(retry.py:393-406),primary task 正常完成 → Phase 5 "先了结的是失败则等另一路"(retry.py:505-509)原样承接,attempt 级记账(breaker/attempt_fails/遥测 error 行)在返回前已完成 ✓——设计 §11-N9 完全可由既有路径表达。
**5. 换算与钟纪律 — 成立。** N8 唯一换算处落点核实:retry.py:346 是现状唯一 int 换算点;sink 三元组升级的消费方仅 `__call__`:289、`_record_generation`:578、`_register_hedge`:593 三处,全在 retry.py 且计划 §3.2 已枚举,无测试直调这两个私有函数(grep 实证)。N4 钟同源核实:client.py:243 `now=time.monotonic` 默认与 transport 内 `time.monotonic` 同钟 ✓。`record_generation` 加 kw-only 默认 None 对 embedding.py:391/ocr.py:407/test_types.py:648-669 零冲击 ✓。
**6. 遥测链路 — 取舍安全,一处 fake 例外。** emit_attempt 加默认 None kw 对全部 ~45 处真实 emitter 直调(均关键字形态,grep 全量清点)与 embedding.py:532/ocr.py:611 安全 ✓;三处装配闸负例在协议 +2 参后仍不满足 `signature.bind`,负例仍绿 ✓(逻辑推理成立:残缺签名缺的不止两参)。**例外即 B1**:RetryMW._emit 是 emit_attempt 的调用方且将显式转发两 kw,"新参默认 None"只保护调用方、不保护被调的显式签名 fake。
**7. 配置守卫 — 落点属实,返回契约有缺口。** check_hedge_assembly 调用点四处(client.py:252、config.py:384、test_config.py:1174/:1184)全关键字形态,加 kw-only 参可行;有效最小值守卫与 openai_compat.py:573 回落同款 ✓;B4 落点核实:EmbeddingSettings.__post_init__ :865 存在且持 `gateway` 字段 ✓,OcrSettings :904 确无 `__post_init__`(计划"新增"表述准确)✓。**缺口**:归一化 stall_after_s 的返回路径未定义(现状返回 `float|None`),二元组返回会炸 test_config.py:1184 的 `is None` 断言,与批次 E 冲突(见 B2);早退须改写为"两阈值皆 None 才早退"(见 B2 附带)。
**8. 默认关闭回归 — 论证成立。** T1 恒传 None(retry.py:275 等)✓、入口门第一分支逐字保留 ✓;stall 序列无条件记录(content_event=None 只跳过 set,不跳过时间戳)→ 流式 ≥2 chunk 恒填 0.0/0.0 或实际值、非流式/<2 恒 None——**计量独立于对冲开关正是 P1 意图,设计与计划一致** ✓。TransportResult 新字段不破坏回归门:test_openai_compat.py 无相等断言、无 asdict/fields 计数(grep 实证)✓;CallStats 新字段默认 None 且无 `== CallStats` 断言 ✓。**字面例外**:B1/N1 所列硬编码计数断言与 test_config.py:1184 是批次 E"不改一行既有断言"的真实例外,计划应显式登记而非被实施者发现。
### 发现列表
| # | 级别 | 位置 | 问题 | 修法 |
| --- | --- | --- | --- | --- |
| B1 | 阻断(事实错误,修法小) | plan §2.3 #8;tests/unit/test_hedge.py:121-133 | RecordingEmitter.emit_attempt 是显式全签名 fake(收 8 个 kw,无 `**kwargs`);T3 后 `_emit` 显式转发 `stall_ms=`/`stall_count=` → TypeError,test_hedge.py 全部成功路径用例红,且违反批次 D/E "1.3.7 既有断言一字不改"承诺。"新参默认 None"的论证只护调用方,护不住被调 fake | §2.3 #8 拆两行:test_backpressure.py:233(`*args, **kwargs`)真零改动;test_hedge.py:122 列入 T3 同步清单(加 `stall_ms=None, stall_count=None` 两 kw,与 T1 fake 签名同步同款处置);批次 E 措辞澄清"fake 签名同步不属于断言改动" |
| B2 | 阻断(契约占位,违反计划自设标准) | plan §3.5 守卫行;config.py:772-849;test_config.py:1171-1190 | 计划要求 "RetryMW 传 stall_after_s=归一化值",但归一化值如何出 check_hedge_assembly 未定义(§3 自称"可执行定义,禁止占位");最自然的二元组返回炸 test_config.py:1184 的 `is None` 断言,与批次 E 直接冲突。附带:§3.5"同 after is None 既有早退形态"字面执行会让仅 stall 配置绕过守卫②③④(批次 D 能抓住,但属误导) | §3.5 钉死返回契约(建议 `tuple[float\|None, float\|None]` 或等值命名结果,config.py:384 与 client.py:252 两处随之解构);test_config.py:1174/:1184 列入 T4 同步清单;早退条件改写为"两阈值皆 None 才早退";批次 E 例外登记本处 + B1 + N1 |
| N1 | 建议 | test_ports.py:313、test_telemetry.py:748(连带 :650/:743/:756 注释文本) | 两处硬编码计数断言(`len(params)-1 == 36`、`len(physical) == 37`)未列入批次 C 清单;T3 验收门会自愈,但破坏"清单即全量"承诺 | 补入批次 C 扩展点(36→38、37→39),注释文本顺手同步 |
| N2 | 建议 | plan §2.1/§8 行号表 | `_attempt_hedged` :371-530 实为 :415-531;另有 5 处 ±1-6 漂移 | 执行前以 grep 复核为准(§6 已有此纪律),修正 §2.1 表防误锚 |
| N3 | 建议 | plan T4 先红清单 | "键未识 ValueError" 不准确:3 段未识键被 config.py:431 静默跳过,实际红因是 AttributeError/TypeError | 措辞修正即可,红成立 |
| N4 | 建议 | middleware/telemetry.py:368 | docstring "组装 36 字段" 未列入计数同步范围(ports.py:280/sqlite.py:146/schema.py:8 已覆盖) | T3 顺手同步,防计数漂移 |
| N5 | 建议 | tests/unit/test_hedge.py:150 `_harness(stall_window_s=300.0)` | 既有 `stall_window_s`(StallClock 预算)与新 `stall_after_s` 命名相邻,新文件复制设施时易混 | test_stall_hedge.py 设施复用时加注释区隔两概念 |
**Merge verdict: OK with notes** — B1/B2 修计划文本(均为小时级,不改任何设计语义)后即可按书执行;TDD 门对全部 N 项有自愈能力。
@@ -0,0 +1,29 @@
---
type: review
node_id: review:2026-10-01-issue29-cache-review
title: 保真缓存审查修复与1.4.0准备
date: 2026-10-01
---
# 保真缓存审查修复与1.4.0准备
## 审查采纳
| 级别/问题 | 核验与处置 |
| --- | --- |
| Important/P2:空协议缓存绕过成功判据 | 已实证:新测试在修复前 10 failed、1 passed;role-only、null/空/空白正文+空工具、reasoning-only,分别带空投影/伪造旧投影均误命中。采纳并修复。 |
| Minor:协议/治理测试互相导入 | 已核验双向依赖;将帧构造放基础协议测试中,上层 fidelity 单向复用,没有新增 helpers 抽象。 |
内核 `types._has_completion_output` 供 transport 和缓存共用:先经协议类型结构校验,有完整工具即有效;无工具须原协议正文和兼容投影均非空。快照类型仍允许描述空消息,不将成功判据强塞进构造器。缓存使用已有 warning/miss,text 旧缓存行为不变。没有 middleware→transport 依赖。
## 验证证据
定向 `test_cache/test_openai_completion/test_openai_fidelity/test_openai_compat` 为 268 passed;日志 `tests/outputs/issue29/review-cache-{red,green}.log`(运行产物不提交)。完整质量门1827 passed/23 skipped/110 deselected、95%覆盖、退出0;真实Redis+包13通过;1.4.0 wheel/sdist源码/README/LICENSE逐字校验及twine通过,详见计划最新记录;首轮 lint 发现一个测试 import 顺序,修正后重新运行全门,未掩盖首次失败。
## 发布准备边界
1.4.0 对应新增公共协议支持及 transport 端口参数;2026-10-01 远端 tags 和匿名 registry simple 最大版本均1.3.8,无1.4.0冲突。README安装约束、能力表、CHANGELOG定版、两处版本同步。新增 `.env.example` 仅四行说明,无凭据。源码/许可来源核查复用已独审 finding;新产物仍须父会话最终复核后发布。本轮不合并、不 push/tag/上传、不关issue;原main脏文件未改。
## 后续版本定案(2026-10-01 EDT / 2026-10-02 UTC)
用户最终指定发布 **1.3.9**,取代本文当时准备但从未发布的 1.4.0。本文标题、历史构建及审查证据保留原值;当前发布目标与新构建核验见[计划 §7](../plans/2026-10-01-issue29-openai-fidelity.md)。版本号变更不撤销自定义 Transport 的 `response_mode` 适配要求。
@@ -0,0 +1,76 @@
---
type: review
node_id: review:2026-10-22-issue34-branch-review
title: "#34 OCR 整份文档解析 合并前验收与审查报告"
date: 2026-10-22
severity: approved
---
# #34 OCR 整份文档解析 合并前验收与审查报告
- 分支 `fix/issue34-ocr-document`,基线 `25a90eb`(1.3.10),Head `cb1e08f`(17 个提交,目标 1.3.11;本报告随后追加一个文档提交)。
- 设计 `designs/2026-10-21-issue34-ocr-document-design.md`(已获人类批准:H1/H5/H6 批准,H2 授权另开 #35/#36,H3 待下游确认,H4 发布逐步等指令);计划 `plans/2026-10-21-issue34-ocr-document.md`。
- **结论**: 实现与验证完成,Critical 0、Important 已全部修复。**发布(合并/tag/上传/Release/关 #34)未执行,等人类指令。**
## 1. 需求覆盖(issue #34 → 实现)
| issue 期望 | 实现 | 证据 |
| --- | --- | --- |
| 上传按类型设 filename/Content-Type | 库按**内容**判定(H5 有意替换「调用方声明」): 图片魔数优先 → 前 1KB 内 `%PDF-` → `doc.pdf`,其余沿用 `image.jpg`;三个方法共用 | `test_monkey_ocr.py::TestSniffMedia`、`TestUploadLabelPerMethod`、`TestParseDocument::test_upload_label_matrix_for_the_document_entry`;活体 `recognize_text(PDF)` 返回真实文本而非幻觉串 |
| 逐块 类型/页码/bbox/文字,服务阅读序 | `OcrDocumentElement`(继承 `OcrLayoutElement` + `text`);只读 `_middle.json`;表格为 HTML | `test_monkey_document.py`(§5.1 契约逐行);4 份真实包离线冒烟 29/89/30/29 块全部与 middle/content_list(反转义)一致 |
| 整篇 Markdown | `OcrDocumentResult.markdown`,按名精确配对 `.md` | 同上;真实包 `markdown == .md` 原文 |
| 长文档超时与熔断确认 | 库内无延迟型判定(grep 已证);`timeout_s` 非总耗时上界,硬上限 = `call_deadline_s ≤ lease_ttl_s`;文档线独立 scope 配方 | `TestNoLatencyJudgement`(含已有失败计数/半开探针两种前置状态)、持续慢速下载 → `CallDeadlineExceeded`、README「运维须知」 |
| `parse_layout` 不动 | 签名/返回类型/错误文案不变;仅上传分支(H6)与共用第一段协议新增 `download_url` 校验 | 独立审查对 5501 个输入与 2486 个场景做基线差分,仅 `%PDF-` 字样载荷的上传标签有差异(已登记) |
## 2. 本会话证据(命令 → 结果)
| 声明 | 命令 | 结果 |
| --- | --- | --- |
| 全套通过 | `pytest tests/ -q --cov=src/polygateway`(退出码取自文件,无管道) | 退出 0;**2239 passed, 23 skipped, 113 deselected(slow)**;总覆盖 96% |
| 格式/lint/依赖契约 | `make ci` | 退出 0;108 files formatted、ruff All checks passed、`Contracts: 1 kept, 0 broken` |
| 复杂度 | `radon cc <ocr.py, _monkey_document.py, monkey_ocr.py> -n C -s` | 无 C 及以上(仅 `openai_compat.py::complete` 为未触及的既有 C) |
| 活体 | `pytest tests/integration/test_monkey_ocr_document_live.py -m slow` | 3 passed(8.85s);默认套件 3 deselected |
| 真实多页 PDF 端到端 | `OcrClient.from_env("OCR_DOC")` 调 `rich.pdf`(4 页)/`tbl.pdf`(2 页含表) | 89/30 个元素,与直连存证一致;表格 HTML 取出;各 1 次尝试 |
| 红→绿 | 每任务 `scratchpad/T*-red.log` → `T*-green.log` | T1–T6 红灯原因均为目标行为缺失(导入失败或 `AttributeError: no attribute 'parse_document'`) |
| 测试非空转 | 变异检查(还原/改坏实现 → 测试转红 → 恢复) | 本会话十余类变异全部被抓到: 异常边界放错位置(47 个失败)、`status==500`/前缀判据、同步解析、体积检查先于错误状态、`_summarize`、早期装配校验、整包上限、`aread`/有界读取、`reasoning_applies`、探针归还、`download_url` 校验、`isinstance` 分派;独立审查另做 86 个变异、0 存活 |
## 3. 评审轮次与处置
| 轮次 | 审查者 | 结论 | 处置 |
| --- | --- | --- | --- |
| 设计论证 | 独立 Plan subagent(含本机 MonkeyOCR 源码核对) | 弃「content_list 对位」改 middle-only 等 | 全部采纳(设计 §12) |
| 设计审查 | Codex(只读) | 否→修订后进入;9 条全部成立 | 全部修订(设计「审查记录」) |
| 计划审查 | Codex(只读) | 修订后进入执行;10 条全部成立,第 1 条为我方计划缺陷 | 全部修订(计划 §5) |
| 合并前独立验证 | 全新上下文 verifier | 无 Critical;Important 4 条 | I-1 异常漏出、I-2 整包上限无测试、I-3 业务词汇、I-4 三处空转测试——**全部修复**并用变异证明 |
| **整分支 Codex 审查** | Codex | **未完成**: 沙箱报 usage limit,只读了设计/计划/部分代码即终止,无任何分级结论 | **以全新上下文的独立 Claude 对抗式审查替代**(见下行);Codex 额度恢复后可补跑,不影响本报告其余结论 |
| 对抗式代码审查 | 全新上下文 Claude(自写模糊/变异脚本,假服务压测) | 建议合并;Critical 0,Important 3 | 见 §4 |
## 4. 对抗式审查发现与处置
| # | 发现 | 核验 | 处置 |
| --- | --- | --- | --- |
| I-1 | `_summarize` 由 `hasattr` 改 `isinstance`,鸭子类型 transport 结果以 `AttributeError` 漏出 | 本机复现(`SimpleNamespace(text=...)`) | 改回**结构分派**(有 `markdown` → 文档;有 `text` → 文本;其余版面),加回归测试 |
| I-2 | 文档路径的错误体 `aread()` 无上限(600 MiB 的 500 使 RSS 涨到 1.8 GiB) | 属实(基线非流式 `get` 同样,但新代码声称有界) | 错误体只读前 64 KiB 并复制成已缓冲响应;加测试证明只消费上限那么多 |
| I-3 | 畸形 `download_url`(控制字符/坏方括号/越界端口)让 `httpx.InvalidURL` 漏出;探针不归还 | 本机复现;`parse_layout` 基线同样 | 在共用的 `_post_parse` 一处校验,归瞬时错误、不发第二段请求;`parse_layout` 顺带受益(CHANGELOG 登记);**不限制主机**(基线接受绝对地址) |
| M-1 | 嗅探副作用: 前 1KB 含 `%PDF-` 字样的非 PDF 文本被改标;`BM` 两字节魔数太弱 | 属实 | BMP 加 DIB 头大小校验;CHANGELOG/README 登记副作用 |
| M-2 | 字节上限≠内存上限(JSON 解析后可达 23 倍;成员条数无上限) | 属实,仅服务异常时成立 | README 改为诚实口径;不实现 EOCD 预读(真实样本约 72 KB/页) |
| M-3 | `to_thread` 不是「立即生效」: GIL 下事件循环仍有停顿,取消后线程自然收尾 | 属实 | 文档改「基本不被占住」并给实测数 |
| M-4 | `parse_layout` 的 ZIP 路径仍会漏原生异常(基线即有) | 属实,按设计保持原样 | **后续项**(见 §6) |
| M-5/6/7 | 设计文档结局表与实现不一致;类 docstring 过时;测试注释含业务词汇 | 属实 | 全部修正 |
| M-8 | `page_idx` 必须等于页序,服务若跳页则整份失败 | 设计取舍 | 保留;列入未验证项 |
## 5. 没能验证的部分(如实登记)
- **线上服务**: 旋转页/带批注页/加密 PDF 的 `doc.pdf` vs `image.jpg` 差分;带密码 PDF 的服务端报错文案(若不在标记集合,走瞬时重试并计熔断);线上 backend 是否 sync-only(队头阻塞);真实 httpcore 逐次读超时行为(MockTransport 不执行它);`need_drop` 恒为 False 的线上证实。
- **样本局限**: 多行/多 span 文本块、`inline_equation` span、嵌套 caption/footnote 三条路径无真实样本,仅由构造 fixture 测试(已在 CHANGELOG/README 标注「未经实测」)。
- **未执行**: `make wiki-check`(需外部 Gitea Wiki 克隆路径,站点自 2026-08-02 下线);Redis 限流/熔断后端与 Postgres 遥测对新入口的组合(无对应环境新增用例,既有套件中的后端用例通过);整分支 Codex 审查(额度)。
- **下游**: H3(CHSAnalyzer3 是否把「服务不可用」一族按延期重投处理)未向下游证实。
## 6. 后续项与待人类指令
| 项 | 状态 |
| --- | --- |
| #35 共享 `_classify_status` 对确定性坏输入 500 仍归瞬时 | 已开 |
| #36 `recognize_text` 不校验 `success` | 已开 |
| `parse_layout` ZIP 路径的原生异常漏出(OverflowError/NotImplementedError/RuntimeError)与 `download_url` 主机限制 | 人类 2026-10-22 批准新开,已开 #37 |
| 发布: 合并 main → push → tag `v1.3.11` → 构建 → 上传 registry → 验证已发布 → 建 Release/挂仓库 → 关闭 #34 | 人类 2026-10-22 下令发布,按 CLAUDE.md §4.4.1 逐步执行;CHANGELOG 定版日期 2026-10-22 |
+24 -1
View File
@@ -32,7 +32,7 @@ date: 2026-07-20
| thinking_observation | TEXT | 本次推理是否真的发生的三态裁定(2026-08-25,issue #16/#17);`observed` / `absent` / `unknown`。见下方口径 | | thinking_observation | TEXT | 本次推理是否真的发生的三态裁定(2026-08-25,issue #16/#17);`observed` / `absent` / `unknown`。见下方口径 |
| reasoning_effort | TEXT | 按四种行来源记录的推理意图/实际编码档位(2026-09-09 澄清,issue #20/#26);八档 `Effort` 字面量之一,NULL = 调用方未表态(与 `none`「明确要求不推理」不可混同)。见下方口径 | | reasoning_effort | TEXT | 按四种行来源记录的推理意图/实际编码档位(2026-09-09 澄清,issue #20/#26);八档 `Effort` 字面量之一,NULL = 调用方未表态(与 `none`「明确要求不推理」不可混同)。见下方口径 |
| scope | TEXT | 池名(2026-09-09,issue #19/#23);Emitter 构造期注入,三类行都带,**不拿 `source_name` 顶替** | | scope | TEXT | 池名(2026-09-09,issue #19/#23);Emitter 构造期注入,三类行都带,**不拿 `source_name` 顶替** |
| operation | TEXT | `chat` / `embed` / `recognize_text` / `parse_layout`,由调用点给定;与 `PolyGatewayError.operation` 是两个语义,链路上不得读后者填本列 | | operation | TEXT | `chat` / `embed` / `recognize_text` / `parse_layout` / `parse_document`(2026-10-22,#34),由调用点给定;与 `PolyGatewayError.operation` 是两个语义,链路上不得读后者填本列 |
| logical_call_id | TEXT | 一次逻辑调用一个 ID(重试/换源/重问/分批共享);上下文缺席落 NULL,**不造 ID** | | logical_call_id | TEXT | 一次逻辑调用一个 ID(重试/换源/重问/分批共享);上下文缺席落 NULL,**不造 ID** |
| event_kind | TEXT | 三态 `attempt` / `cache_hit` / `terminal_failure`——三类行的唯一机械判据。见下方口径 | | event_kind | TEXT | 三态 `attempt` / `cache_hit` / `terminal_failure`——三类行的唯一机械判据。见下方口径 |
| http_status_code | INTEGER | 失败 attempt 行的实收状态码(中转改写过就记改写后的,**不猜回原值**);成功行与终态行恒 NULL,且 200 也可能是失败行(MonkeyOCR `success != true`) | | http_status_code | INTEGER | 失败 attempt 行的实收状态码(中转改写过就记改写后的,**不猜回原值**);成功行与终态行恒 NULL,且 200 也可能是失败行(MonkeyOCR `success != true`) |
@@ -177,3 +177,26 @@ ORDER BY model, calls DESC;
## 1.3.4 测试侧证据(不新增 schema) ## 1.3.4 测试侧证据(不新增 schema)
`tests/live_evidence.py`/e2e conftest 只在内存保存完整错误体与独立非流式身份,逐轮 Markdown 白名单输出到 `tests/outputs/134/live/`;凭据、Authorization、提示词、原始异常/响应均不落报告。生产数据仍经 TelemetryEmitter。评估复用 `metric:call-telemetry-coverage`,实际 live 覆盖基线待首次执行。 `tests/live_evidence.py`/e2e conftest 只在内存保存完整错误体与独立非流式身份,逐轮 Markdown 白名单输出到 `tests/outputs/134/live/`;凭据、Authorization、提示词、原始异常/响应均不落报告。生产数据仍经 TelemetryEmitter。评估复用 `metric:call-telemetry-coverage`,实际 live 覆盖基线待首次执行。
## #29 复用声明(2026-10-01)
OpenAI 保真没有新增遥测表/列;仍经 RetryMW._emit → TelemetryEmitter。
error 帧是失败 attempt,正文与思考仍为兼容投影,不新增工具全文列。
本页原标题/表格停留 36 字段;实际 `telemetry/schema.py` 已含 stall_ms/stall_count
共 38 个 INSERT 字段(§7.8 已登记)。本次不改 DDL,真实定义以源码为准。
## 1.3.10 `stream_end` 列(#31,2026-10-14)
`llm_calls` 末尾追加可空 `stream_end TEXT`:INSERT 字段 38 → 39,物理列(含 `created_at`)39 → 40;
上文"36 字段"标题为历史口径,以 `telemetry/schema.py` 的 `COLUMNS` 为准。
| 值 | 含义 | 行 |
| --- | --- | --- |
| `done` | 收到 `[DONE]` | 流式成功 attempt |
| `eof_after_finish` | 无 DONE,finish + 其后实测 usage + 干净 EOF | 流式成功 attempt |
| `salvaged` | 文本 `missing_done=salvage` 打捞 | 流式成功 attempt |
| NULL | 非流式 / 缓存命中 / 失败 / 取消 / 终态行,或补列前旧行 | — |
来源: emitter 经 `_AttemptUsage.of(response)` 读 `LLMResponse.stream_end`(不走 `emit_attempt` kw);
命中与终态入口显式 None。评估复用 `metric:call-telemetry-coverage`;新增观察口径
`SELECT stream_end, count(*) FROM llm_calls WHERE event_kind='attempt' GROUP BY 1` 统计中转不发 DONE 的比例。
+13 -1
View File
@@ -10,6 +10,7 @@ from polygateway.config import EmbeddingSettings, GatewaySettings, OcrSettings
from polygateway.embedding import EmbeddingClient from polygateway.embedding import EmbeddingClient
from polygateway.errors import ( from polygateway.errors import (
AllSourcesExhausted, AllSourcesExhausted,
CallDeadlineExceeded,
CircuitOpenError, CircuitOpenError,
GatewayUnavailableError, GatewayUnavailableError,
GovernanceBackendError, GovernanceBackendError,
@@ -43,15 +44,20 @@ from polygateway.types import (
Effort, Effort,
EmbeddingResponse, EmbeddingResponse,
LLMResponse, LLMResponse,
OcrDocumentElement,
OcrDocumentResult,
OcrLayoutElement, OcrLayoutElement,
OcrLayoutResult, OcrLayoutResult,
OcrTextResult, OcrTextResult,
OpenAIChatCompletion,
ResponseMode,
SourceConfig, SourceConfig,
StreamEnd,
TelemetryStatus, TelemetryStatus,
ThinkingObservation, ThinkingObservation,
) )
__version__ = "1.3.5" __version__ = "1.3.11"
__all__ = [ __all__ = [
"DEFAULT_PROFILES", "DEFAULT_PROFILES",
@@ -59,6 +65,7 @@ __all__ = [
"Effort", "Effort",
"AllSourcesExhausted", "AllSourcesExhausted",
"CallStats", "CallStats",
"CallDeadlineExceeded",
"CircuitOpenError", "CircuitOpenError",
"EmbeddingClient", "EmbeddingClient",
"EmbeddingResponse", "EmbeddingResponse",
@@ -68,8 +75,13 @@ __all__ = [
"GatewayUnavailableError", "GatewayUnavailableError",
"GovernanceBackendError", "GovernanceBackendError",
"LLMResponse", "LLMResponse",
"ResponseMode",
"StreamEnd",
"OpenAIChatCompletion",
"ModelPrice", "ModelPrice",
"OcrClient", "OcrClient",
"OcrDocumentElement",
"OcrDocumentResult",
"OcrLayoutElement", "OcrLayoutElement",
"OcrLayoutResult", "OcrLayoutResult",
"OcrSettings", "OcrSettings",
+85 -2
View File
@@ -9,6 +9,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import copy
import dataclasses import dataclasses
import hashlib import hashlib
import json import json
@@ -19,7 +20,8 @@ from typing import TYPE_CHECKING, Any, Literal
from polygateway.backends.memory.breaker import InMemoryGate from polygateway.backends.memory.breaker import InMemoryGate
from polygateway.backends.memory.cache import InMemoryCache from polygateway.backends.memory.cache import InMemoryCache
from polygateway.backends.memory.limiter import InMemoryLimiter from polygateway.backends.memory.limiter import InMemoryLimiter
from polygateway.config import GatewaySettings from polygateway.config import GatewaySettings, check_hedge_assembly
from polygateway.deadline import ensure_call_deadline, with_call_deadline
from polygateway.errors import PolyGatewayError from polygateway.errors import PolyGatewayError
from polygateway.middleware.base import compose from polygateway.middleware.base import compose
from polygateway.middleware.cache import CacheMW from polygateway.middleware.cache import CacheMW
@@ -47,6 +49,7 @@ from polygateway.types import (
ChatRequest, ChatRequest,
Effort, Effort,
LLMResponse, LLMResponse,
ResponseMode,
TelemetryStatus, TelemetryStatus,
_CallContext, _CallContext,
coerce_effort, coerce_effort,
@@ -235,10 +238,38 @@ class GatewayClient:
structured_strategy: StructuredOutputStrategy | None = None, structured_strategy: StructuredOutputStrategy | None = None,
structured_escalation: StructuredOutputStrategy | None = None, structured_escalation: StructuredOutputStrategy | None = None,
structured_max_retries: int = 1, structured_max_retries: int = 1,
call_deadline_s: float | None = None,
hedge_after_s: float | None = None,
hedge_max_extra: int = 1,
hedge_stall_after_s: float | None = None,
stall_threshold_ms: int | None = None,
now: Any = time.monotonic, now: Any = time.monotonic,
sleep: Any = asyncio.sleep, sleep: Any = asyncio.sleep,
rng: Any = random.random, rng: Any = random.random,
) -> None: ) -> None:
# 入口即校: 装配错误当场报,不等到第一次调用才炸
self._call_deadline_s = ensure_call_deadline(
call_deadline_s, "GatewayClient(call_deadline_s=...)"
)
# 对冲守卫与 GatewaySettings 共用同一份(issue #24 H4 / 1.3.8 #28): 直接构造这条路
# 不经过 settings,值域/交叉守卫若只挂在 settings 上就会被它绕过。
# 返回契约(1.3.8 审查 B2): tuple(归一化 after_s, 归一化 stall_after_s)
self._hedge_after_s, self._hedge_stall_after_s = check_hedge_assembly(
hedge_after_s=hedge_after_s,
hedge_max_extra=hedge_max_extra,
sources=sources,
call_deadline_s=self._call_deadline_s,
origin="GatewayClient(hedge_after_s=...)",
stall_after_s=hedge_stall_after_s,
stall_origin="GatewayClient(hedge_stall_after_s=...)",
)
# 沉默计量阈值分发(1.3.8 设计 §6): None = 保留各 SourceConfig 自带值
# (不 stomp 直构造调用方的 per-source 设置);非 None 经 replace 写进每个源
# (replace 触发 SourceConfig.__post_init__ 重跑,值域校验复用同一处)
if stall_threshold_ms is not None:
sources = [
dataclasses.replace(s, stall_threshold_ms=stall_threshold_ms) for s in sources
]
emitter = ( emitter = (
TelemetryEmitter(telemetry, scope=scope, pricing=pricing, text_cap=text_cap) TelemetryEmitter(telemetry, scope=scope, pricing=pricing, text_cap=text_cap)
if telemetry is not None if telemetry is not None
@@ -261,6 +292,10 @@ class GatewayClient:
ceiling=float(max([64, *(s.max_concurrency for s in sources if s.max_concurrency)])) ceiling=float(max([64, *(s.max_concurrency for s in sources if s.max_concurrency)]))
), ),
emitter=emitter, emitter=emitter,
# max_extra 不下传(issue #24 H5): v1 编排固定单路对冲,无消费者
hedge_after_s=self._hedge_after_s,
# 中段沉默对冲阈值(1.3.8 #28): 独立于 hedge_after_s 生效,守卫归一化值
stall_after_s=self._hedge_stall_after_s,
now=now, now=now,
sleep=sleep, sleep=sleep,
rng=rng, rng=rng,
@@ -292,6 +327,8 @@ class GatewayClient:
self._structured_available = structured_strategy is not None self._structured_available = structured_strategy is not None
self._terminal = terminal # 内部引用: 装配自省/测试用 self._terminal = terminal # 内部引用: 装配自省/测试用
self._handler = compose(middlewares, terminal) self._handler = compose(middlewares, terminal)
# 期限到期需要报出 scope(现之前只传给 RetryMW,未自存)
self._scope = scope
# 逻辑调用统计需要同一只注入钟(1.3.5);现之前只传给中间件未自存 # 逻辑调用统计需要同一只注入钟(1.3.5);现之前只传给中间件未自存
self._now = now self._now = now
# 终态行由公开边界统一写出(T3),故边界也需持有 emitter # 终态行由公开边界统一写出(T3),故边界也需持有 emitter
@@ -336,9 +373,16 @@ class GatewayClient:
reasoning_effort: Effort | str | None = None, reasoning_effort: Effort | str | None = None,
tenant_id: str | None = None, tenant_id: str | None = None,
meta: Mapping[str, Any] | None = None, meta: Mapping[str, Any] | None = None,
call_deadline_s: float | None = None,
response_mode: ResponseMode = "text",
) -> LLMResponse: ) -> LLMResponse:
"""一次治理调用(签名冻结,ARCH §5.2;与三项目 LLMProvider 协议兼容)。 """一次治理调用(签名冻结,ARCH §5.2;与三项目 LLMProvider 协议兼容)。
`response_mode="openai"` 返回 `completion` 完整单 choice 消息快照;
不支持与 structured 并用,不执行工具。空正文但完整工具是合法结果;
缺 [DONE] 不打捞。默认 text 保持既有文本投影。返回模式不绕过受管推理
校验;raw-only 要求源级与请求级推理意图均为 None。
`overlay` 是采样参数覆盖层(`temperature`/`seed`/`max_tokens` 等),优先级 `overlay` 是采样参数覆盖层(`temperature`/`seed`/`max_tokens` 等),优先级
高于源级 `extra_body`、低于结构化输出的注入。带默认值的 keyword-only 高于源级 `extra_body`、低于结构化输出的注入。带默认值的 keyword-only
参数不影响既有调用点(issue #4)。 参数不影响既有调用点(issue #4)。
@@ -351,7 +395,29 @@ class GatewayClient:
`tenant_id` 与 `meta` 是调用方自定义维度,只进遥测、**不进缓存 key** `tenant_id` 与 `meta` 是调用方自定义维度,只进遥测、**不进缓存 key**
(租户隔离由 `cache_namespace` 负责,ARCH §7.5);前者享有真实列待遇 (租户隔离由 `cache_namespace` 负责,ARCH §7.5);前者享有真实列待遇
(可挂 RLS、可进复合索引),后者是任意 KV 容器(issue #11)。 (可挂 RLS、可进复合索引),后者是任意 KV 容器(issue #11)。
`call_deadline_s` 是本次调用的墙钟硬边界(issue #22): `None` = 继承装配值,
正数 = 本次覆盖,**不提供"本次关闭"**。它治理的是**等待**: 到期抛
`CallDeadlineExceeded`,但到期**不等于未产出、未计费**——在途请求可能已发出、
已被上游计费,且清理仍在 `finally` 里完成,故返回时刻 = 期限 + 清理耗时。
""" """
if response_mode not in ("text", "openai"):
raise ValueError("response_mode 必须为 text 或 openai")
if response_mode == "openai":
if structured is not None:
raise ValueError("openai 模式不能与 structured 并用")
if (
overlay is not None
and "n" in overlay
and (type(overlay["n"]) is not int or overlay["n"] != 1)
):
raise ValueError("openai 模式仅支持 n=1")
try:
json.dumps(messages, allow_nan=False)
except (ValueError, TypeError) as exc:
raise ValueError("messages 必须可 JSON 序列化") from exc
messages = copy.deepcopy(messages)
overlay = copy.deepcopy(dict(overlay)) if overlay is not None else None
if structured is not None and not self._structured_available: if structured is not None and not self._structured_available:
raise ImportError( raise ImportError(
"结构化输出未启用: 安装 pip install 'polygateway[structured]' 后重新装配" "结构化输出未启用: 安装 pip install 'polygateway[structured]' 后重新装配"
@@ -377,10 +443,18 @@ class GatewayClient:
else coerce_effort(reasoning_effort, origin="chat(reasoning_effort=...)") else coerce_effort(reasoning_effort, origin="chat(reasoning_effort=...)")
) )
validate_thinking_raw(sampling, effort=effort, wire=None, origin="chat overlay") validate_thinking_raw(sampling, effort=effort, wire=None, origin="chat overlay")
# 期限取值与校验必须在创建 awaitable **之前**: 否则非法值抛错时会遗留
# 未 await 的协程(RuntimeWarning + 资源不释放)
deadline = (
self._call_deadline_s
if call_deadline_s is None
else ensure_call_deadline(call_deadline_s, "chat(call_deadline_s=...)")
)
# 三项校验均已通过 → 进入统计边界(设计 §3: 输入校验异常在边界之外,保持原行为) # 三项校验均已通过 → 进入统计边界(设计 §3: 输入校验异常在边界之外,保持原行为)
context = _CallContext(now=self._now) context = _CallContext(now=self._now)
request = ChatRequest( request = ChatRequest(
messages=messages, messages=messages,
response_mode=response_mode,
session_id=session_id, session_id=session_id,
parent_call_id=parent_call_id, parent_call_id=parent_call_id,
cache_salt=cache_salt, cache_salt=cache_salt,
@@ -395,7 +469,9 @@ class GatewayClient:
call_context=context, call_context=context,
) )
try: try:
response = await self._handler(request) response = await with_call_deadline(
self._handler(request), deadline_s=deadline, scope=self._scope
)
except PolyGatewayError as exc: except PolyGatewayError as exc:
# 统计边界内的一切领域失败均尝试写一条终态行(1.3.5 设计 §6 I3), # 统计边界内的一切领域失败均尝试写一条终态行(1.3.5 设计 §6 I3),
# 包括已有 attempt 错误行的 RequestRejected / ResultInvalid——两类行描述 # 包括已有 attempt 错误行的 RequestRejected / ResultInvalid——两类行描述
@@ -485,6 +561,12 @@ class GatewayClient:
structured_strategy=strategy, structured_strategy=strategy,
structured_escalation=escalation, structured_escalation=escalation,
structured_max_retries=settings.structured_max_retries, structured_max_retries=settings.structured_max_retries,
call_deadline_s=settings.call_deadline_s,
hedge_after_s=settings.hedge_after_s,
hedge_max_extra=settings.hedge_max_extra,
hedge_stall_after_s=settings.hedge_stall_after_s,
# settings 值为权威,恒分发(1.3.8 §3.5): 含缺省 1000 在内
stall_threshold_ms=settings.stall_threshold_ms,
) )
_mark_owned_components(client, limiter=limiter, breaker=breaker, telemetry=telemetry) _mark_owned_components(client, limiter=limiter, breaker=breaker, telemetry=telemetry)
client._owns_cache = cache is None # 缓存后端可以是 None(backend=none),helper 会跳过 client._owns_cache = cache is None # 缓存后端可以是 None(backend=none),helper 会跳过
@@ -610,6 +692,7 @@ async def gather_bounded[T](aws: Iterable[Awaitable[T]], *, concurrency: int) ->
"""有界并发 gather(D5 便利函数,替代 VT 手搓 semaphore+gather 样板)。 """有界并发 gather(D5 便利函数,替代 VT 手搓 semaphore+gather 样板)。
语义与 `asyncio.gather` 默认一致: 结果保序、首个异常上抛;仅增加并发上限。 语义与 `asyncio.gather` 默认一致: 结果保序、首个异常上抛;仅增加并发上限。
期限计时从每次调用真正开始执行起算,信号量排队时长不在 `call_deadline_s` 之内。
""" """
if concurrency < 1: if concurrency < 1:
raise ValueError("concurrency 必须 ≥ 1") raise ValueError("concurrency 必须 ≥ 1")
+263 -2
View File
@@ -19,6 +19,7 @@ from typing import TYPE_CHECKING
from dotenv import dotenv_values from dotenv import dotenv_values
from loguru import logger from loguru import logger
from polygateway.deadline import ensure_call_deadline
from polygateway.types import ( from polygateway.types import (
BackpressurePolicy, BackpressurePolicy,
BreakerConfig, BreakerConfig,
@@ -29,7 +30,7 @@ from polygateway.types import (
) )
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Mapping from collections.abc import Mapping, Sequence
# FIELD → (SourceConfig 属性, 类型);CHS config.py:95-104 全集 + M1 新增 # FIELD → (SourceConfig 属性, 类型);CHS config.py:95-104 全集 + M1 新增
_SOURCE_FIELDS: dict[str, tuple[str, str]] = { _SOURCE_FIELDS: dict[str, tuple[str, str]] = {
@@ -53,7 +54,7 @@ _SOURCE_FIELDS: dict[str, tuple[str, str]] = {
"TRUST_ENV": ("trust_env", "bool"), "TRUST_ENV": ("trust_env", "bool"),
"EXTRA_BODY": ("extra_body", "json"), "EXTRA_BODY": ("extra_body", "json"),
} }
_RESERVED_SEGMENTS = frozenset({"GLOBAL", "RETRY", "BREAKER", "BACKPRESSURE"}) _RESERVED_SEGMENTS = frozenset({"GLOBAL", "RETRY", "BREAKER", "BACKPRESSURE", "HEDGE", "STREAM"})
_SELECTORS = frozenset({"round_robin", "least_inflight", "health_aware"}) _SELECTORS = frozenset({"round_robin", "least_inflight", "health_aware"})
_QUOTA_FULL = frozenset({"wait", "fail_fast"}) _QUOTA_FULL = frozenset({"wait", "fail_fast"})
# 熔断全拒时的处置(issue #14);值域与 _QUOTA_FULL 相同但语义不同——配额满是 # 熔断全拒时的处置(issue #14);值域与 _QUOTA_FULL 相同但语义不同——配额满是
@@ -182,6 +183,27 @@ class GatewaySettings:
pricing_path: str | None pricing_path: str | None
structured_max_retries: int structured_max_retries: int
lease_ttl_s: float lease_ttl_s: float
# 一次逻辑调用的**可选**墙钟硬边界(issue #22)。缺省 None = 不启用,行为逐字
# 等于 1.3.5;有默认值故追加在末尾,不扰动既有位置构造。`EmbeddingSettings.gateway`
# 与 `OcrSettings.gateway` 自动继承。值域由 `_validate_call_deadline` 把关,
# 直接构造、`dataclasses.replace` 与 env 三条路一致
call_deadline_s: float | None = None
# 长尾对冲(issue #24 H4): 挂起超阈值时并发向异源再发一次,先回者赢、输家取消。
# 缺省 None = 关闭,行为逐字等于 1.3.6。`hedge_max_extra` 值域 [1,3] 为 H5 梯次
# 预留,v1 仅单路生效(>1 装配期 warning);**不下传 RetryMW**。值域与交叉守卫
# 由 `check_hedge_assembly` 单一定义点把关,直接构造/replace/env 三路一致
hedge_after_s: float | None = None
hedge_max_extra: int = 1
# 中段沉默对冲(1.3.8 issue #28): 相邻增量 chunk 间隔超阈值 → 并发异源副路。
# 缺省 None = 关闭;独立于 hedge_after_s 生效(设计 §9-④);仅对流式 chat 请求
# 生效,非流式请求运行期静默退化(入口门)。值域与交叉守卫同归
# `check_hedge_assembly` 单一定义点
hedge_stall_after_s: float | None = None
# 沉默计量阈值(1.3.8 issue #27): 相邻增量 chunk 间隔**严格大于**它才计入
# stall_ms 并从 generation_ms 扣减。默认 1000ms(设计 §9-③ 拍板,三层可调);
# 只影响计量口径,不改任何行为。值域(正整数)归 `SourceConfig.__post_init__`
# 一处——分发经 `dataclasses.replace` 时重跑,本字段不重复校验
stall_threshold_ms: int = 1000
def __post_init__(self) -> None: def __post_init__(self) -> None:
self._normalize() self._normalize()
@@ -192,6 +214,8 @@ class GatewaySettings:
self._validate_lease() self._validate_lease()
self._validate_stall() self._validate_stall()
self._validate_probe() self._validate_probe()
self._validate_call_deadline()
self._validate_hedge()
def _normalize(self) -> None: def _normalize(self) -> None:
"""把 `from_env` 一直在做的规范化补到构造路上,两条路必须产出同一个值。 """把 `from_env` 一直在做的规范化补到构造路上,两条路必须产出同一个值。
@@ -345,6 +369,37 @@ class GatewaySettings:
f"timeout_s + {_PROBE_GRACE_S}({floor});调大 probe_ttl_s 或调小源的 timeout_s" f"timeout_s + {_PROBE_GRACE_S}({floor});调大 probe_ttl_s 或调小源的 timeout_s"
) )
def _validate_call_deadline(self) -> None:
"""期限值域守卫: 盖住直接构造与 `dataclasses.replace` 两条路(issue #22)。
env 路已在 `_load_call_deadline` 里带真实键名报过错, 此处对合法值是幂等空操作。
**不校验**它与 `timeout_s`/`stall_window_s` 的大小关系: 期限短于单次超时
是调用方的合法选择(要的就是“不让这次调用拖过 N 秒”)。
"""
object.__setattr__(
self,
"call_deadline_s",
ensure_call_deadline(self.call_deadline_s, "GatewaySettings.call_deadline_s"),
)
def _validate_hedge(self) -> None:
"""对冲装配守卫(issue #24 H4): 与期限同款,盖住直接构造与 replace 两条路。
env 路的值域错误已在 `_load_hedge` 里带真实键名报过;此处对合法值是幂等
空操作,交叉守卫(阈值 vs timeout/deadline/ttft、单源)只在这里有一处。
"""
after_s, stall_after_s = check_hedge_assembly(
hedge_after_s=self.hedge_after_s,
hedge_max_extra=self.hedge_max_extra,
sources=self.sources,
call_deadline_s=self.call_deadline_s,
origin="GatewaySettings.hedge_after_s",
stall_after_s=self.hedge_stall_after_s,
stall_origin="GatewaySettings.hedge_stall_after_s",
)
object.__setattr__(self, "hedge_after_s", after_s)
object.__setattr__(self, "hedge_stall_after_s", stall_after_s)
@classmethod @classmethod
def from_env( def from_env(
cls, cls,
@@ -373,6 +428,9 @@ class GatewaySettings:
selector=_load_choice(env, f"{scope_u}__SELECTOR", _SELECTORS, "health_aware"), selector=_load_choice(env, f"{scope_u}__SELECTOR", _SELECTORS, "health_aware"),
quota_full=_load_choice(env, f"{scope_u}__QUOTA_FULL", _QUOTA_FULL, "wait"), quota_full=_load_choice(env, f"{scope_u}__QUOTA_FULL", _QUOTA_FULL, "wait"),
circuit_open=_load_choice(env, f"{scope_u}__CIRCUIT_OPEN", _CIRCUIT_OPEN, "fail_fast"), circuit_open=_load_choice(env, f"{scope_u}__CIRCUIT_OPEN", _CIRCUIT_OPEN, "fail_fast"),
call_deadline_s=_load_call_deadline(scope_u, env),
**_load_hedge(scope_u, env),
**_load_stream(scope_u, env),
**_load_pgw(env), **_load_pgw(env),
) )
@@ -671,6 +729,204 @@ def _load_lease_ttl(env: Mapping[str, str]) -> float:
return float(_cast(found[1], "float", found[0])) if found else _DEFAULT_LEASE_TTL_S return float(_cast(found[1], "float", found[0])) if found else _DEFAULT_LEASE_TTL_S
def _load_call_deadline(scope: str, env: Mapping[str, str]) -> float | None:
"""读 `{SCOPE}__CALL_DEADLINE_S`(issue #22);键未设即 None = 不启用。
用 `_first` 而非 `_require`: 后者会把"未设"当成配置缺失报错,对存量下游
就是破坏性变更。键名两段式(`split("__")` 长度 2 ≠ 4),故 `_load_sources`
天然跳过它,不必进 `_RESERVED_SEGMENTS`。
origin 传**实际命中的 env 键名**而非字段名: `_cast` 只接得住"不是数字",
`0`/负数/`inf` 会穿过它落到 `ensure_call_deadline`——那时报一条指向字段名的错误,
在多 scope 部署里无法定位是哪个键写错了。
Args:
scope: 已大写的 scope 名。
env: 已合并的环境映射。
Returns:
一次逻辑调用的墙钟期限(秒);键未设或为空串时返回 None(不启用)。
"""
found = _first(env, f"{scope}__CALL_DEADLINE_S")
if found is None:
return None
return ensure_call_deadline(_cast(found[1], "float", found[0]), found[0])
def _load_stream(scope: str, env: Mapping[str, str]) -> dict[str, object]:
"""读 `{SCOPE}__STREAM__STALL_THRESHOLD_MS`(1.3.8 issue #27 设计 §6)。
3 段键(`split("__")` 长度 3 ≠ 4),`_load_sources` 的段数判据天然跳过;
`STREAM` 已进 `_RESERVED_SEGMENTS`,4 段的 `{SCOPE}__STREAM__{N}__*` 也不会
被当成 provider 段造出源(1.3.7 `HEDGE` 同款)。未设 = 1000(设计 §9-③ 拍板);
值域(正整数)归 `SourceConfig.__post_init__` 一处——字段分发经
`dataclasses.replace` 时重跑,本函数只负责解析。
Args:
scope: 已大写的 scope 名。
env: 已合并的环境映射。
Returns:
`{"stall_threshold_ms": int}`,直传构造器。
"""
found = _first(env, f"{scope}__STREAM__STALL_THRESHOLD_MS")
threshold = int(_cast(found[1], "int", found[0])) if found else 1000
return {"stall_threshold_ms": threshold}
def _load_hedge(scope: str, env: Mapping[str, str]) -> dict[str, object]:
"""读 `{SCOPE}__HEDGE__AFTER_S`/`MAX_EXTRA`/`STALL_AFTER_S`(issue #24 H4 + 1.3.8 #28)。
两键均为 3 段键(`split("__")` 长度 3 ≠ 4),`_load_sources` 的段数判据天然
跳过它们;`HEDGE` 已进 `_RESERVED_SEGMENTS`,4 段的 `{SCOPE}__HEDGE__{N}__*`
也不会被当成 provider 段造出源。`AFTER_S` 未设 = 关闭(缺省逐字等于 1.3.6);
`MAX_EXTRA` 未设 = 1。origin 传实际命中键名(同 `_load_call_deadline` 纪律);
值域的交叉守卫(ttft/单源/max_extra 生效口径)归 `check_hedge_assembly` 一处。
Args:
scope: 已大写的 scope 名。
env: 已合并的环境映射。
Returns:
`{"hedge_after_s": float | None, "hedge_max_extra": int}`,直传构造器。
"""
found_after = _first(env, f"{scope}__HEDGE__AFTER_S")
after = (
ensure_call_deadline(_cast(found_after[1], "float", found_after[0]), found_after[0])
if found_after
else None
)
found_extra = _first(env, f"{scope}__HEDGE__MAX_EXTRA")
extra = int(_cast(found_extra[1], "int", found_extra[0])) if found_extra else 1
found_stall = _first(env, f"{scope}__HEDGE__STALL_AFTER_S")
stall = (
ensure_call_deadline(_cast(found_stall[1], "float", found_stall[0]), found_stall[0])
if found_stall
else None
)
return {"hedge_after_s": after, "hedge_max_extra": extra, "hedge_stall_after_s": stall}
def check_hedge_assembly(
*,
hedge_after_s: float | None,
hedge_max_extra: int,
sources: Sequence[SourceConfig],
call_deadline_s: float | None,
origin: str,
stall_after_s: float | None = None,
stall_origin: str = "hedge_stall_after_s",
) -> tuple[float | None, float | None]:
"""对冲装配守卫的唯一事实源(issue #24 设计 §5 全表,H4 批准 + 1.3.8 issue #28 §6)。
`GatewaySettings.__post_init__` 与 `GatewayClient.__init__` 调同一份,两条装配
路的值域/交叉守卫不漂移。返回契约(1.3.8 审查 B2):
`tuple(归一化 hedge_after_s, 归一化 stall_after_s)`(各为 None 或有限正数,
复用 `ensure_call_deadline` 的值域校验);**两阈值皆 None 才早退**——只对
`hedge_after_s is None` 早退会让仅 stall 配置绕过交叉守卫(审查 B2 钉死)。
Args:
hedge_after_s: 首 token/整体耗时对冲触发阈值(秒);None = 关闭。
hedge_max_extra: 每次逻辑调用最多并发对冲路数;v1 仅单路生效(H5)。
sources: 本 scope 的源集合(交叉守卫要读 timeout_s/ttft/inter_token)。
call_deadline_s: 调用期限(秒);与对冲的组合守卫见设计 §6。
origin: after_s 值域报错的定位串(env 键名 / `GatewaySettings.hedge_after_s` /
`GatewayClient(hedge_after_s=...)`);跨字段守卫与 warning 沿用本模块先例,
消息自带字段名与 env 键型,不挂 origin。
stall_after_s: 中段沉默对冲触发阈值(秒,1.3.8 issue #28);None = 关闭。
stall_origin: stall_after_s 值域报错的定位串(同 origin 的三路纪律)。
Raises:
ValueError: max_extra 非 int/bool 或出 [1,3];after_s ≥ 最小源 timeout_s(永不
可能触发);stall_after_s ≥ 最小源有效 inter-token 超时(回落同款,窗永不开);
阈值 ≥ call_deadline_s(期限先于对冲触发,对冲形同虚设)。
"""
if isinstance(hedge_max_extra, bool) or not isinstance(hedge_max_extra, int):
raise ValueError(
f"hedge_max_extra({{SCOPE}}__HEDGE__MAX_EXTRA)必须是 int: {hedge_max_extra!r}"
)
if not 1 <= hedge_max_extra <= 3:
raise ValueError(
f"hedge_max_extra({{SCOPE}}__HEDGE__MAX_EXTRA)须在 [1,3]: {hedge_max_extra};"
"每次逻辑调用最多并发对冲路数,v1 仅单路生效"
)
after = ensure_call_deadline(hedge_after_s, origin)
stall = ensure_call_deadline(stall_after_s, stall_origin)
if after is None and stall is None:
return None, None
if not sources:
# 直传路(GatewayClient(sources=[], ...))没有 settings 的
# 非空守卫先行拦截;报错必须定位到 hedge 键族,而不是裸 min() 空序列异常。
# 消息保留 "hedge_after_s" 字样: 既有用例据此定位键族(1.3.8 扩含 stall)
raise ValueError(
"hedge_after_s/hedge_stall_after_s({SCOPE}__HEDGE__AFTER_S/STALL_AFTER_S)"
"的交叉守卫要求 sources 不能为空;对冲已启用但没有可校验的源"
)
min_timeout = min(s.timeout_s for s in sources)
if after is not None:
if after >= min_timeout:
raise ValueError(
f"hedge_after_s({{SCOPE}}__HEDGE__AFTER_S={after})须 < 最小源 timeout_s"
f"({min_timeout});对冲永不可能触发,配置即错误"
)
if call_deadline_s is not None and after >= call_deadline_s:
raise ValueError(
f"hedge_after_s({after})须 < call_deadline_s({call_deadline_s});"
"期限会先于对冲触发,对冲形同虚设(设计 §6)"
)
ttfts = [s.ttft_timeout_s for s in sources if s.ttft_timeout_s is not None]
if ttfts and after >= min(ttfts):
logger.warning(
"hedge_after_s({}) ≥ 最小源 ttft_timeout_s({}): 流式挂起会被 TTFT 看门狗"
"先行切断,对冲对流式形同虚设(非流式仍有效)",
after,
min(ttfts),
)
if stall is not None:
# 有效最小值与 openai_compat.py:573 看门狗装配的回落同款:
# 配了 watchdog 的源按 inter_token_timeout_s,未配的按 timeout_s(设计 §11-N5)
min_inter = min((s.inter_token_timeout_s or s.timeout_s) for s in sources)
if stall >= min_inter:
raise ValueError(
f"hedge_stall_after_s({{SCOPE}}__HEDGE__STALL_AFTER_S={stall})须 < 最小源"
f"有效 inter-token 超时({min_inter};inter_token_timeout_s 缺省回落"
f"timeout_s,与看门狗装配同款);中段对冲窗永不开,配置即错误"
)
if call_deadline_s is not None and stall >= call_deadline_s:
raise ValueError(
f"hedge_stall_after_s({stall})须 < call_deadline_s({call_deadline_s});"
"期限会先于中段对冲触发,对冲形同虚设(设计 §6)"
)
if len(sources) == 1:
logger.warning(
"单源 scope 配置了对冲阈值 hedge_after_s={}/hedge_stall_after_s={};"
"运行期拿不到异源候选,对冲自然静默",
after,
stall,
)
if hedge_max_extra > 1:
logger.warning(
"hedge_max_extra={} 已接受,但 v1 仅单路对冲生效(梯次追加为 H5 预留)",
hedge_max_extra,
)
return after, stall
def _warn_hedge_keys_on_non_stream_scope(gateway: GatewaySettings, scope_kind: str) -> None:
"""B4(1.3.8 设计 §11): 装配期可证明纯非流式的 scope 配了对冲键 → warning。
chat scope 不做(`stream` 是 per-call 参数,装配期无信号,B4 裁决);对冲键在
Embedding/OCR 链路没有任何消费者,静默退化必须说出来。`hedge_after_s` 一并
检测(1.3.7 同病顺带补齐,缺陷修复不扩公共面)。
"""
if gateway.hedge_after_s is not None or gateway.hedge_stall_after_s is not None:
logger.warning(
"对冲键(HEDGE__AFTER_S/HEDGE__STALL_AFTER_S)仅对 chat scope 生效: "
"{} scope 非流式,对冲配置静默退化(1.3.8 B4)",
scope_kind,
)
@dataclass(frozen=True) @dataclass(frozen=True)
class EmbeddingSettings: class EmbeddingSettings:
"""Embedding scope 装配配置(M2 §7): 复用 GatewaySettings + embedding 专用键。 """Embedding scope 装配配置(M2 §7): 复用 GatewaySettings + embedding 专用键。
@@ -691,6 +947,7 @@ class EmbeddingSettings:
raise ValueError(f"EmbeddingSettings.batch_size 必须 ≥ 1: {self.batch_size}") raise ValueError(f"EmbeddingSettings.batch_size 必须 ≥ 1: {self.batch_size}")
if self.expected_dim is not None and self.expected_dim < 1: if self.expected_dim is not None and self.expected_dim < 1:
raise ValueError(f"EmbeddingSettings.expected_dim 必须 ≥ 1: {self.expected_dim}") raise ValueError(f"EmbeddingSettings.expected_dim 必须 ≥ 1: {self.expected_dim}")
_warn_hedge_keys_on_non_stream_scope(self.gateway, "embedding")
@classmethod @classmethod
def from_env( def from_env(
@@ -734,6 +991,10 @@ class OcrSettings:
gateway: GatewaySettings gateway: GatewaySettings
def __post_init__(self) -> None:
"""内嵌 gateway 由 `GatewaySettings.__post_init__` 自己把关;此处只补 B4 对冲键 warning。"""
_warn_hedge_keys_on_non_stream_scope(self.gateway, "ocr")
@classmethod @classmethod
def from_env( def from_env(
cls, cls,
+80
View File
@@ -0,0 +1,80 @@
"""一次逻辑调用的**可选**墙钟硬边界(issue #22;1.3.6 设计 §3 方案 A)。
只依赖标准库与 `errors.py`(依赖铁律最内层),供三个公开边界各包一次:
期限治理的是**等待**,不是"到期即无副作用"——在途请求可能已发出、已被上游
计费,清理照旧在 `finally` 完成,故返回时刻 = 期限 + 清理耗时。
缺省 `None` 时**完全不进上下文管理器**,行为逐字等于 1.3.5。
"""
from __future__ import annotations
import asyncio
import math
from typing import TYPE_CHECKING
from polygateway.errors import CallDeadlineExceeded
if TYPE_CHECKING:
from collections.abc import Awaitable
def ensure_call_deadline(value: object, origin: str) -> float | None:
"""全装配路径共用的期限值域校验: `None` 或**有限正数秒**,否则当场 `ValueError`。
装配错误不属降级面(缺失/非法配置直接报错,不静默取默认值)。`bool` 必须先判:
`isinstance(True, int)` 为真,放行会让 `call_deadline_s=True` 变成"1 秒期限"
这种没人写得出来的意图。巨大 int(如 `10**400`)超出 float 值域,`float()` 会抛
`OverflowError`——它不是 `ValueError` 的子类,泄漏出去会绕过调用方的
`except ValueError`,故在此统一成同一种装配错误。
`origin` 写进消息,用于在多 scope 部署里定位到底是哪个键/哪个参数非法。
"""
if value is None:
return None
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValueError(f"{origin} 必须是 None 或有限正数秒: {value!r}")
try:
seconds = float(value)
except OverflowError:
raise ValueError(f"{origin} 必须是有限正数秒: {value!r}") from None
if not math.isfinite(seconds) or seconds <= 0:
raise ValueError(f"{origin} 必须是有限正数秒: {value!r}")
return seconds
async def with_call_deadline[T](aw: Awaitable[T], *, deadline_s: float | None, scope: str) -> T:
"""给一个 awaitable 加一层可选期限;到期抛 `CallDeadlineExceeded`。
三条实现红线:
1. **校验先于构造 awaitable**——调用方必须先 `ensure_call_deadline`,否则非法值
抛错时会遗留未 await 的协程(`RuntimeWarning` + 资源不释放)。
2. 只用**相对时长**,绝不把注入的 `now` 换算成绝对截止时刻: 注入钟跳变
10^6 秒不该凭空触发期限。
3. 判据必须是**局部变量身份比较**,不可退化成只看 `cm.expired()`:
到期后清理路径自抛的 `TimeoutError` 也发生在 `expired()` 为真时,只看它
会把别人的超时改标成本层期限;`__cause__` 启发式同样失效(内层
`asyncio.timeout` 抛出的 `TimeoutError` 其 `__cause__` 也是 `CancelledError`)。
不新增后台任务、不 `shield`、不改异常对象:外部取消照常以 `CancelledError` 穿透。
"""
if deadline_s is None:
# 未启用: 不进上下文管理器,逐字走 1.3.5 旧路径
return await aw
# 体内(含清理路径)自抛的 TimeoutError 的**身份**,唯一可靠的区分依据
inner_timeout: BaseException | None = None
# 先建对象再进上下文: `as cm` 只在 `__aenter__` 返回后才绑定, 而 except 块无条件
# 读 `cm`——进入阶段一旦抛 TimeoutError 就会变成 NameError 掩盖真实错误
cm = asyncio.timeout(deadline_s)
try:
async with cm:
try:
return await aw
except TimeoutError as exc:
inner_timeout = exc
raise
except TimeoutError as exc:
if cm.expired() and exc is not inner_timeout:
raise CallDeadlineExceeded(scope=scope, deadline_s=deadline_s) from None
raise
+41 -4
View File
@@ -28,6 +28,7 @@ from loguru import logger
from polygateway.client import _aclose_component, _telemetry_status_of from polygateway.client import _aclose_component, _telemetry_status_of
from polygateway.config import EmbeddingSettings from polygateway.config import EmbeddingSettings
from polygateway.deadline import ensure_call_deadline, with_call_deadline
from polygateway.errors import ( from polygateway.errors import (
AllSourcesExhausted, AllSourcesExhausted,
GovernanceBackendError, GovernanceBackendError,
@@ -112,6 +113,7 @@ class EmbeddingClient:
batch_size: int, batch_size: int,
normalize: bool = False, normalize: bool = False,
expected_dim: int | None = None, expected_dim: int | None = None,
call_deadline_s: float | None = None,
now: Callable[[], float] = time.monotonic, now: Callable[[], float] = time.monotonic,
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep, sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
rng: Callable[[], float] = random.random, rng: Callable[[], float] = random.random,
@@ -120,6 +122,10 @@ class EmbeddingClient:
raise ValueError("batch_size 必须 ≥ 1") raise ValueError("batch_size 必须 ≥ 1")
if expected_dim is not None and expected_dim < 1: if expected_dim is not None and expected_dim < 1:
raise ValueError("expected_dim 必须 ≥ 1") raise ValueError("expected_dim 必须 ≥ 1")
# 入口即校: 装配错误当场报,不等到第一次调用才炸
self._call_deadline_s = ensure_call_deadline(
call_deadline_s, "EmbeddingClient(call_deadline_s=...)"
)
self._scope = scope self._scope = scope
# embed payload 硬编码 {model, input},带 extra_body 的源必须先剥离, # embed payload 硬编码 {model, input},带 extra_body 的源必须先剥离,
# 否则遥测会记录一个从未发出的采样参数(issue #4 决策 G) # 否则遥测会记录一个从未发出的采样参数(issue #4 决策 G)
@@ -174,11 +180,16 @@ class EmbeddingClient:
parent_call_id: str | None = None, parent_call_id: str | None = None,
tenant_id: str | None = None, tenant_id: str | None = None,
meta: Mapping[str, Any] | None = None, meta: Mapping[str, Any] | None = None,
call_deadline_s: float | None = None,
) -> EmbeddingResponse: ) -> EmbeddingResponse:
"""一次治理 embedding 调用: 按 batch_size 切批,批间串行,全批合并返回。 """一次治理 embedding 调用: 按 batch_size 切批,批间串行,全批合并返回。
`tenant_id` 与 `meta` 是调用方自定义维度,只进遥测(issue #11);它们属于 `tenant_id` 与 `meta` 是调用方自定义维度,只进遥测(issue #11);它们属于
本次调用而非某一批,故每批的遥测行都带同一份维度。 本次调用而非某一批,故每批的遥测行都带同一份维度。
`call_deadline_s` 是本次调用的墙钟硬边界(issue #22): `None` = 继承装配值。
**整次调用共享一份**——N 批串行跑在同一条期限内,不按批数放大 N 倍。
`texts == []` 的早返回在期限之外(零尝试,无等待可治)。
""" """
if not isinstance(texts, list) or any(not isinstance(t, str) for t in texts): if not isinstance(texts, list) or any(not isinstance(t, str) for t in texts):
raise TypeError("texts 必须是 list[str](显式优于隐式,不收单条 str)") raise TypeError("texts 必须是 list[str](显式优于隐式,不收单条 str)")
@@ -188,6 +199,12 @@ class EmbeddingClient:
dimension_tenant_id, dimensions = validate_caller_dimensions( dimension_tenant_id, dimensions = validate_caller_dimensions(
tenant_id, meta, origin="embed(tenant_id=..., meta=...)" tenant_id, meta, origin="embed(tenant_id=..., meta=...)"
) )
# 期限取值与校验必须在创建 awaitable **之前**(否则遗留未 await 的协程)
deadline = (
self._call_deadline_s
if call_deadline_s is None
else ensure_call_deadline(call_deadline_s, "embed(call_deadline_s=...)")
)
# 校验均已通过 → 进入统计边界(设计 §3.5: `texts` 类型与调用方维度校验之后) # 校验均已通过 → 进入统计边界(设计 §3.5: `texts` 类型与调用方维度校验之后)
context = _CallContext(now=self._now) context = _CallContext(now=self._now)
if not texts: if not texts:
@@ -206,8 +223,12 @@ class EmbeddingClient:
call_stats=context.snapshot(), call_stats=context.snapshot(),
) )
try: try:
return await self._embed_all( return await with_call_deadline(
self._embed_all(
texts, session_id, parent_call_id, dimension_tenant_id, dimensions, context texts, session_id, parent_call_id, dimension_tenant_id, dimensions, context
),
deadline_s=deadline,
scope=self._scope,
) )
except PolyGatewayError as exc: except PolyGatewayError as exc:
await self._emit_terminal( await self._emit_terminal(
@@ -343,10 +364,17 @@ class EmbeddingClient:
call_id = str(uuid.uuid4()) call_id = str(uuid.uuid4())
started = self._now() started = self._now()
actual = 0 actual = 0
# 局部阶段变量(与 RetryMW 同口径): 该刻库是否已算出确定结算。只服务于取消
# 分支的兜底取值,不进任何签名; 未分类异常逃逸时仍逐字走旧的全额退还。
settlement_known = False
# 登记在 transport 调用**之前**(同 RetryMW): 失败与取消的尝试也真的发出去了 # 登记在 transport 调用**之前**(同 RetryMW): 失败与取消的尝试也真的发出去了
context.register_attempt() context.register_attempt()
try: try:
# 裸生成时间(1.3.7 H8): 计时只包 transport 调用本身(同 RetryMW 口径),
# 与本链路 total_latency_ms 同一只注入钟;分批累加在成功分支登记
gen_started = self._now()
result = await self._transport.embed(texts=batch, source=source, call_id=call_id) result = await self._transport.embed(texts=batch, source=source, call_id=call_id)
gen_ms = int((self._now() - gen_started) * 1000)
if self._expected_dim is not None and result.dim != self._expected_dim: if self._expected_dim is not None and result.dim != self._expected_dim:
raise ResultInvalidError( raise ResultInvalidError(
f"{source.name} 维度 {result.dim} 不符期望 {self._expected_dim}", f"{source.name} 维度 {result.dim} 不符期望 {self._expected_dim}",
@@ -358,6 +386,9 @@ class EmbeddingClient:
actual = source.effective_est_tokens() actual = source.effective_est_tokens()
else: else:
actual = result.prompt_tokens actual = result.prompt_tokens
# 真实 usage 恰为 0 也是已知事实, 后续取消不得改写成 est
settlement_known = True
context.record_generation(gen_ms, accumulate=True)
await self._record_quietly(self._breaker.record_success(entry)) await self._record_quietly(self._breaker.record_success(entry))
await self._record_quietly(self._quota.mark_progress()) await self._record_quietly(self._quota.mark_progress())
latency_ms = int((self._now() - started) * 1000) latency_ms = int((self._now() - started) * 1000)
@@ -375,6 +406,7 @@ class EmbeddingClient:
) )
return _BatchOutcome(result, source, call_id, latency_ms) return _BatchOutcome(result, source, call_id, latency_ms)
except (RequestRejectedError, ResultInvalidError) as exc: except (RequestRejectedError, ResultInvalidError) as exc:
actual, settlement_known = 0, True # 逐字保住 1.3.5 口径(本版不改这一族记账)
await self._gate_on_terminal(exc, entry) await self._gate_on_terminal(exc, entry)
await self._emit( await self._emit(
batch, batch,
@@ -390,6 +422,9 @@ class EmbeddingClient:
) )
raise raise
except asyncio.CancelledError: except asyncio.CancelledError:
if not settlement_known:
# 端口已开始、结算未定: 保守保留预扣(设计 §6.3 S7)
actual = source.effective_est_tokens()
if entry.is_probe: if entry.is_probe:
await self._record_quietly(self._breaker.release_probe(entry)) await self._record_quietly(self._breaker.release_probe(entry))
await self._emit( await self._emit(
@@ -409,10 +444,11 @@ class EmbeddingClient:
dead = isinstance(exc, SourceDeadError) dead = isinstance(exc, SourceDeadError)
reason = _failure_reason(exc) reason = _failure_reason(exc)
reasons[source.name] = reason reasons[source.name] = reason
# 结算决定定死在本分支第一个 await 之前: 同级 except CancelledError 接不住
# 落在本块 await 上的取消,它直穿 finally。值与 1.3.5 逐字相同,只是算得更早。
actual = 0 if dead else source.effective_est_tokens()
settlement_known = True
await self._record_quietly(self._breaker.record_failure(entry, reason, dead)) await self._record_quietly(self._breaker.record_failure(entry, reason, dead))
if not dead:
# 保守: 失败请求可能已被网关计费(CHS 同款);与入场预扣同源取值
actual = source.effective_est_tokens()
await self._emit( await self._emit(
batch, batch,
source, source,
@@ -624,6 +660,7 @@ class EmbeddingClient:
batch_size=settings.batch_size, batch_size=settings.batch_size,
normalize=settings.normalize, normalize=settings.normalize,
expected_dim=settings.expected_dim, expected_dim=settings.expected_dim,
call_deadline_s=gw.call_deadline_s,
) )
_mark_owned_components(client, limiter=limiter, breaker=breaker, telemetry=telemetry) _mark_owned_components(client, limiter=limiter, breaker=breaker, telemetry=telemetry)
return client return client
+21 -1
View File
@@ -37,7 +37,7 @@ SOURCE_REASONS = frozenset(
class PolyGatewayError(Exception): class PolyGatewayError(Exception):
"""库内一切领域错误的基类,携带来源上下文便于遥测与日志定位。 """库内一切领域错误的基类,携带来源上下文便于遥测与日志定位。
`body_text` 是**非 2xx 响应体的摘要**——网关拒绝这次调用时说的话(issue #10)。 `body_text` 是**HTTP 失败或 200 chat error 帧的摘要**——网关拒绝这次调用时说的话(issue #10)。
它与 `ResultInvalidError.raw_text` 是两回事,严禁混用: 它与 `ResultInvalidError.raw_text` 是两回事,严禁混用:
============== ================================================== ============== ==================================================
@@ -217,3 +217,23 @@ class GovernanceBackendError(GatewayUnavailableError):
# 父类会把 message 覆写为 "{scope} 网关暂时不可用: {reason}",而各构造点 # 父类会把 message 覆写为 "{scope} 网关暂时不可用: {reason}",而各构造点
# 携带的诊断串(如"限流后端 try_acquire 失败: ...")是排障主线索,必须保住 # 携带的诊断串(如"限流后端 try_acquire 失败: ...")是排障主线索,必须保住
self.args = (message,) self.args = (message,)
class CallDeadlineExceeded(PolyGatewayError): # noqa: N818 — 设计 §9 人类批准的公共名
"""调用方设定的整体调用期限到期; 不是网关不可用、也不是源故障。
刻意**不属**四分类、**不进** `SCOPE_REASONS`、**不继承** `GatewayUnavailableError`:
它描述的是调用方自己的耐心边界, 与"对方怎么了"正交——按四分类之一上报会让
下游的重试/换源/熔断逻辑对着一次本地超时做治理决策(库铁律「错误分类驱动」)。
也刻意**没有** `retry_after_s`: 期限到期不含"何时可再试"的信息, 给 `0.0`
会按既定语义指示下游立刻重打一条可能已经饱和的通道。
**到期不等于未产出、未计费**: 期限治理的是等待, 在途请求可能已经发出、
已被上游计费, 清理仍在 `finally` 里完成, 故返回时刻 = 期限 + 清理耗时。
"""
def __init__(self, *, scope: str, deadline_s: float) -> None:
super().__init__(f"{scope} 调用期限 {deadline_s}s 到期")
self.scope = scope
self.deadline_s = deadline_s
+15 -2
View File
@@ -169,15 +169,28 @@ class SourceAdmission:
# —— 选源与准入(CHS _pick_runnable 120-167)—— # —— 选源与准入(CHS _pick_runnable 120-167)——
async def pick( async def pick(
self, reasons: dict[str, str], attempt_fails: dict[str, int] self,
reasons: dict[str, str],
attempt_fails: dict[str, int],
*,
exclude: frozenset[str] | None = None,
) -> tuple[tuple[SourceConfig, Permit, GateDecision] | None, int]: ) -> tuple[tuple[SourceConfig, Permit, GateDecision] | None, int]:
"""挑出第一个过闸的候选;返回 (选中三元组 | None, 熔断类拒绝计数)。""" """挑出第一个过闸的候选;返回 (选中三元组 | None, 熔断类拒绝计数)。
`exclude` 是对冲编排的私有排除参数(issue #24): 以在途源名为排除集,
保证对冲路落在**异源**。被排除不是源的拒绝——不计 gate_rejections、
不写 reasons,否则会污染 `on_no_runnable` 的分派判据与 per_source_reasons
对账。拿不到候选时返回 None,是否放弃由调用方决定(对冲方静默等原路,
**严禁**对这个 None 调 `on_no_runnable`)。
"""
stats = {s.name: await self._quota.stats(s) for s in self._sources} stats = {s.name: await self._quota.stats(s) for s in self._sources}
gate_rejections = 0 gate_rejections = 0
ordered = _demote_call_failures( ordered = _demote_call_failures(
self._selector.order(self._sources, stats), attempt_fails, self._health_view self._selector.order(self._sources, stats), attempt_fails, self._health_view
) )
for cand in ordered: for cand in ordered:
if exclude and cand.name in exclude:
continue # 对冲排除在途源: 不是拒绝, 不计数不写原因(见 docstring)
if self._memo.active(cand.name): if self._memo.active(cand.name):
# 冷却备忘跳过也计入拒绝数,保住 circuit_open 判据(CHS 同款) # 冷却备忘跳过也计入拒绝数,保住 circuit_open 判据(CHS 同款)
gate_rejections += 1 gate_rejections += 1
+76 -11
View File
@@ -17,7 +17,16 @@ from typing import TYPE_CHECKING, Any
from loguru import logger from loguru import logger
from polygateway.types import ChatRequest, Effort, LLMResponse, ThinkingObservation from polygateway.types import (
ChatRequest,
Effort,
LLMResponse,
OpenAIChatCompletion,
ResponseMode,
ThinkingObservation,
_effective_finish,
_is_cacheable,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Mapping from collections.abc import Mapping
@@ -107,6 +116,7 @@ def build_cache_key(
*, *,
sampling: Mapping[str, Any] | None = None, sampling: Mapping[str, Any] | None = None,
reasoning_effort: Effort | None = None, reasoning_effort: Effort | None = None,
response_mode: ResponseMode = "text",
) -> str: ) -> str:
"""缓存 key 公式;salt 仅非 None 时参与(VT 旧键语义: 不传 salt 键形不变)。 """缓存 key 公式;salt 仅非 None 时参与(VT 旧键语义: 不传 salt 键形不变)。
@@ -127,6 +137,8 @@ def build_cache_key(
"messages": digest_messages(messages), "messages": digest_messages(messages),
"namespace": namespace, "namespace": namespace,
} }
if response_mode != "text":
key_obj["response_mode"] = response_mode
if salt is not None: if salt is not None:
key_obj["salt"] = salt key_obj["salt"] = salt
if sampling: if sampling:
@@ -137,6 +149,54 @@ def build_cache_key(
return _KEY_PREFIX + hashlib.sha256(payload.encode("utf-8")).hexdigest() return _KEY_PREFIX + hashlib.sha256(payload.encode("utf-8")).hexdigest()
def _response_cacheable(mode: ResponseMode, response: LLMResponse) -> bool:
"""写路径判据;与读路径共用 `_is_cacheable` 与 `_effective_finish`,口径不分叉。"""
return _is_cacheable(
mode=mode,
content=response.content,
finish_reason=_effective_finish(response.finish_reason, response.completion),
stream_end=response.stream_end,
completion=response.completion,
)
def _restore_fields(raw: str, mode: ResponseMode) -> dict[str, Any]:
"""缓存原文 → LLMResponse 字段;不可缓存或形态坏抛 ValueError(调用方按 miss)。
可缓存判定对**存储原值**进行、先于命中覆盖: 覆盖后 `stream_end` 恒 None,
在那之后判会把 salvaged 条目放行(设计 §6)。
"""
data = json.loads(raw)
fields = {k: v for k, v in data.items() if k in _RESPONSE_FIELDS}
completion = fields.get("completion")
if completion is not None:
if not isinstance(completion, dict):
raise ValueError("缓存 completion 必须为对象")
fields["completion"] = OpenAIChatCompletion(**completion)
finish = _effective_finish(fields.get("finish_reason"), fields.get("completion"))
if finish is not None and not isinstance(finish, str):
raise ValueError("缓存 finish_reason 必须为字符串或 null")
if not _is_cacheable(
mode=mode,
content=fields.get("content", ""),
finish_reason=finish,
stream_end=fields.get("stream_end"),
completion=fields.get("completion"),
):
# 非法 stream_end 也落这里: 它关乎内容完整性,不像 thinking_observation
# 那样可以宽容降级
raise ValueError("缓存条目不可缓存(截断/空正文/非自然结束/非法 stream_end)")
fields["finish_reason"] = finish
# JSON 里存的是 StrEnum 的字符串值,不转就复活成裸 str,与字段注解分叉
# (下游 `is ThinkingObservation.OBSERVED` 会在命中路径上静默为 False);
# 键缺失即升级前写入的旧条目,交给 dataclass 默认值
if "thinking_observation" in fields:
fields["thinking_observation"] = _coerce_observation(fields["thinking_observation"])
if "applied_effort" in fields:
fields["applied_effort"] = _coerce_applied_effort(fields["applied_effort"])
return fields
class CacheMW: class CacheMW:
"""洋葱第二层(遥测内、结构化外)。 """洋葱第二层(遥测内、结构化外)。
@@ -176,6 +236,7 @@ class CacheMW:
# 请求级档位必须逐次进 key: `self._fingerprint` 是装配期的集合级指纹, # 请求级档位必须逐次进 key: `self._fingerprint` 是装配期的集合级指纹,
# 同一个 client 上 low 与 max 两次调用在它眼里毫无分别(issue #20) # 同一个 client 上 low 与 max 两次调用在它眼里毫无分别(issue #20)
reasoning_effort=request.reasoning_effort, reasoning_effort=request.reasoning_effort,
response_mode=request.response_mode,
) )
cached = await self._safe_get(key) cached = await self._safe_get(key)
if cached is not None: if cached is not None:
@@ -183,7 +244,17 @@ class CacheMW:
if hit is not None: if hit is not None:
return hit return hit
response = await call_next(request) response = await call_next(request)
await self._safe_set(key, self._serialize(response)) if not _response_cacheable(request.response_mode, response):
# 交付照常,只是不进 TTL: 截断/空正文/非自然结束固化后会把一次偶然
# 变成必然(设计 §6)。debug 级: 这是常规分流,不是故障
logger.debug("结果不可缓存(截断/空正文/非自然结束),跳过写入")
return response
try:
serialized = self._serialize(response)
except Exception as exc:
logger.warning("缓存序列化失败,跳过缓存: {}", type(exc).__name__)
else:
await self._safe_set(key, serialized)
return response return response
# —— 命中路径 —— # —— 命中路径 ——
@@ -191,21 +262,15 @@ class CacheMW:
def _rehydrate(self, raw: str, request: ChatRequest) -> LLMResponse | None: def _rehydrate(self, raw: str, request: ChatRequest) -> LLMResponse | None:
"""反序列化 + 按本次调用的 structured 档零网络重建;失败按未命中。""" """反序列化 + 按本次调用的 structured 档零网络重建;失败按未命中。"""
try: try:
data = json.loads(raw) fields = _restore_fields(raw, request.response_mode)
fields = {k: v for k, v in data.items() if k in _RESPONSE_FIELDS}
structured_data = self._rebuild_structured(fields.get("content", ""), request) structured_data = self._rebuild_structured(fields.get("content", ""), request)
# JSON 里存的是 StrEnum 的字符串值,不转就复活成裸 str,与字段注解分叉
# (下游 `is ThinkingObservation.OBSERVED` 会在命中路径上静默为 False);
# 键缺失即升级前写入的旧条目,交给 dataclass 默认值
if "thinking_observation" in fields:
fields["thinking_observation"] = _coerce_observation(fields["thinking_observation"])
if "applied_effort" in fields:
fields["applied_effort"] = _coerce_applied_effort(fields["applied_effort"])
fields.update( fields.update(
cache_hit=True, cache_hit=True,
latency_ms=0, latency_ms=0,
ttft_ms=None, ttft_ms=None,
max_inter_token_ms=None, max_inter_token_ms=None,
# 结束方式描述的是"那次流",命中没有流可言(与 ttft_ms 同口径)
stream_end=None,
call_id=str(uuid.uuid4()), call_id=str(uuid.uuid4()),
structured_data=structured_data, structured_data=structured_data,
# 显式覆盖: 历史条目里的 `call_stats` 是个 dict,而 `_RESPONSE_FIELDS` # 显式覆盖: 历史条目里的 `call_stats` 是个 dict,而 `_RESPONSE_FIELDS`
+372 -6
View File
@@ -58,6 +58,7 @@ if TYPE_CHECKING:
RetryPolicy, RetryPolicy,
SourceConfig, SourceConfig,
TransportResult, TransportResult,
_CallContext,
) )
@@ -164,6 +165,22 @@ def _is_rate_limited(outcome: LLMResponse | _Failed) -> bool:
return isinstance(outcome, _Failed) and _failure_reason(outcome.exc) == "rate_limited" return isinstance(outcome, _Failed) and _failure_reason(outcome.exc) == "rate_limited"
_HEDGE_LOSER_ATTR = "_polygateway_hedge_loser"
# 生成样本三元组(1.3.8 设计 §4.2 N8): (墙钟 ms, 沉默总额 ms, 超阈值沉默段数)。
# 取整换算全库只在 _attempt 成功分支发生一次,消费侧(_record_generation/
# _register_hedge)只做净口径换算与透传,不再取整。
_GenerationSample = tuple[int, int | None, int | None]
"""编排在 cancel() 之前给输家任务置位的标记;_attempt 读它选遥测标签。"""
def _combine_failures(primary_f: _Failed, hedge_f: _Failed) -> _Failed:
"""任一非 429 优先(计预算);两路皆 429 才按 429 免预算退还 stall 账;同类取原路。"""
if _failure_reason(primary_f.exc) == "rate_limited" != _failure_reason(hedge_f.exc):
return hedge_f
return primary_f
class RetryMW: class RetryMW:
"""尝试编排器;时钟/睡眠/随机全部注入,纯确定性可测(P6)。""" """尝试编排器;时钟/睡眠/随机全部注入,纯确定性可测(P6)。"""
@@ -183,6 +200,8 @@ class RetryMW:
cooldown_memo: SourceCooldownMemo | None = None, cooldown_memo: SourceCooldownMemo | None = None,
pacer: AdaptivePacer | None = None, pacer: AdaptivePacer | None = None,
emitter: TelemetryEmitter | None = None, emitter: TelemetryEmitter | None = None,
hedge_after_s: float | None = None,
stall_after_s: float | None = None,
now: Callable[[], float] = time.monotonic, now: Callable[[], float] = time.monotonic,
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep, sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
rng: Callable[[], float] = random.random, rng: Callable[[], float] = random.random,
@@ -200,6 +219,13 @@ class RetryMW:
# M2.5 §3.35: AIMD 自适应并发——429 收紧、成功回涨,超限调用排队不烧预算 # M2.5 §3.35: AIMD 自适应并发——429 收紧、成功回涨,超限调用排队不烧预算
self._pacer = pacer or AdaptivePacer(ceiling=64.0) self._pacer = pacer or AdaptivePacer(ceiling=64.0)
self._emitter = emitter self._emitter = emitter
# 对冲触发阈值(issue #24): None = 关闭(__call__ 逐字走 1.3.6 单路路径)。
# 值域/交叉守卫在装配层(config.check_hedge_assembly),本类不重复校验;
# hedge_max_extra 不下传——v1 编排固定单路对冲(H5)
self._hedge_after_s = hedge_after_s
# 中段沉默对冲触发阈值(1.3.8 issue #28): None = 关闭;独立于 _hedge_after_s
# 生效(设计 §9-④),仅对流式请求开窗。守卫同在装配层,本类不重复校验
self._stall_after_s = stall_after_s
self._now = now self._now = now
self._sleep = sleep self._sleep = sleep
self._rng = rng self._rng = rng
@@ -246,12 +272,44 @@ class RetryMW:
await self._admission.on_no_runnable(gate_rejections, reasons, clock) await self._admission.on_no_runnable(gate_rejections, reasons, clock)
continue continue
async with clock.attempting() as attempt: async with clock.attempting() as attempt:
outcome = await self._attempt(request, *picked, reasons, attempt_fails) # 每轮一个 sink: 生成样本(墙钟+沉默归因)由编排裁定归属
# (单路 = 成功那轮;对冲 = 赢家那一路),`_attempt` 只负责把本次
# transport 耗时与沉默观测投进来
generation_sink: list[_GenerationSample] = []
if self._hedge_after_s is None and self._stall_after_s is None:
# 两阈值均未设: 逐字 1.3.7 单路路径(默认关闭回归门据此成立)
outcome = await self._attempt(
request,
*picked,
reasons,
attempt_fails,
first_token_event=None,
content_event=None,
generation_sink=generation_sink,
)
elif self._hedge_after_s is None and not request.stream:
# 仅 stall 已设且请求非流式: 中段沉默无非流式概念,逐字走单路
# (等价未配置,设计 §11-B3 入口门)
outcome = await self._attempt(
request,
*picked,
reasons,
attempt_fails,
first_token_event=None,
content_event=None,
generation_sink=generation_sink,
)
else:
# 对冲轮 = 一次"超级尝试": 编排内部自建 sink 并按赢家归属登记
outcome = await self._attempt_hedged(request, picked, reasons, attempt_fails)
rate_limited = _is_rate_limited(outcome) rate_limited = _is_rate_limited(outcome)
if rate_limited: if rate_limited:
# 免了重试预算就得进 stall 账,否则这段耗时无人治理(见 StallClock) # 免了重试预算就得进 stall 账,否则这段耗时无人治理(见 StallClock)
attempt.refund() attempt.refund()
if isinstance(outcome, LLMResponse): if isinstance(outcome, LLMResponse):
# 对冲轮由 _attempt_hedged 按赢家归属登记,此处外层 sink 恒为空
# (守卫在 _record_generation 内: 上下文缺失/空 sink 均跳过)
self._record_generation(request, generation_sink)
return outcome return outcome
if not rate_limited: if not rate_limited:
fails += 1 fails += 1
@@ -275,18 +333,31 @@ class RetryMW:
entry: GateDecision, entry: GateDecision,
reasons: dict[str, str], reasons: dict[str, str],
attempt_fails: dict[str, int], attempt_fails: dict[str, int],
*,
first_token_event: asyncio.Event | None,
content_event: asyncio.Event | None,
generation_sink: list[_GenerationSample],
) -> LLMResponse | _Failed: ) -> LLMResponse | _Failed:
call_id = str(uuid.uuid4()) call_id = str(uuid.uuid4())
started = self._now() started = self._now()
actual = 0 actual = 0
# 局部阶段变量: 该刻库是否已算出**确定**结算。只服务于取消分支的兜底取值,
# 不进任何签名/配置/遥测; 未分类异常逃逸时它无人读取, 故仍逐字走旧的全额退还。
settlement_known = False
# 登记在 transport 调用**之前**(1.3.5 设计 §4): 失败与取消的尝试同样 # 登记在 transport 调用**之前**(1.3.5 设计 §4): 失败与取消的尝试同样
# "真的打出去了",挪到成功之后会让诊断最需要看见的那几次从计数里消失。 # "真的打出去了",挪到成功之后会让诊断最需要看见的那几次从计数里消失。
# 上下文为 None = 库内现场构造的请求,跳过而不是报错 # 上下文为 None = 库内现场构造的请求,跳过而不是报错
if request.call_context is not None: if request.call_context is not None:
request.call_context.register_attempt() request.call_context.register_attempt()
try: try:
# 净生成时间(1.3.8): 此处只量 transport 墙钟(起点紧贴调用前、
# 终点为返回后首句,中间无 await),沉默扣减在消费侧统一换算;
# 取消落进来时 transport 未返回,不计。
# 与该链路 total_latency_ms 同一只注入钟,差值(波动开销)才有意义
gen_started = self._now()
result = await self._transport.complete( result = await self._transport.complete(
messages=request.messages, messages=request.messages,
response_mode=request.response_mode,
source=source, source=source,
stream=request.stream, stream=request.stream,
overlay=request.overlay, overlay=request.overlay,
@@ -294,33 +365,68 @@ class RetryMW:
# 逐次尝试原样重传: 换源不改变调用方要的档位(源级默认由 transport # 逐次尝试原样重传: 换源不改变调用方要的档位(源级默认由 transport
# 自己按选中的源解析,两者在 effective_effort 里汇合) # 自己按选中的源解析,两者在 effective_effort 里汇合)
reasoning_effort=request.reasoning_effort, reasoning_effort=request.reasoning_effort,
# 对冲编排(计划 §3.4): 原路携带首 token 事件,对冲路恒 None(v1 单路,
# 不再梯次);未启用对冲时 __call__ 传 None = 调用方不观测首 token
first_token_event=first_token_event,
# 中段沉默处置位(1.3.8): 未启用 stall 对冲时调用方传 None = 不观测;
# 启用时流式原路携真事件,对冲路恒 None(设计 §5.2 N3③)
content_event=content_event,
) )
# 取整全库唯一处(设计 §4.2 N8): transport 的 float 沉默值在此
# 一次性转 int;None(非流式/增量 chunk<2)透传,消费侧不退减
wall_ms = int((self._now() - gen_started) * 1000)
stall_ms_i = None if result.stall_ms is None else int(result.stall_ms)
stall_count_i = None if result.stall_count is None else int(result.stall_count)
generation_sink.append((wall_ms, stall_ms_i, stall_count_i))
if result.usage_source == "unavailable": if result.usage_source == "unavailable":
# 用量不可得时按入场预扣量结算(delta==0),否则押金会被整笔退回, # 用量不可得时按入场预扣量结算(delta==0),否则押金会被整笔退回,
# 对"从不返回 usage 帧"的源等于 TPM 闸失效(设计 §3.2 #9) # 对"从不返回 usage 帧"的源等于 TPM 闸失效(设计 §3.2 #9)
actual = source.effective_est_tokens() actual = source.effective_est_tokens()
else: else:
actual = result.prompt_tokens + result.completion_tokens actual = result.prompt_tokens + result.completion_tokens
# 真实 usage 恰为 0 也是**已知事实**, 后续取消不得把它改写成 est
settlement_known = True
await self._record_quietly(self._breaker.record_success(entry)) await self._record_quietly(self._breaker.record_success(entry))
await self._record_quietly(self._quota.mark_progress()) await self._record_quietly(self._quota.mark_progress())
self._feed_outcome(source.name, ok=True) self._feed_outcome(source.name, ok=True)
self._pacer.on_success(source.name) self._pacer.on_success(source.name)
response = self._build_response(source, result, call_id, started) response = self._build_response(source, result, call_id, started)
await self._emit(request, source, call_id, started, response=response) await self._emit(
request,
source,
call_id,
started,
response=response,
stall_ms=stall_ms_i,
stall_count=stall_count_i,
)
return response return response
except RequestRejectedError as exc: except RequestRejectedError as exc:
actual, settlement_known = 0, True # 逐字保住 1.3.5: 坏请求全额退还
await self._on_rejected(exc, source, entry) await self._on_rejected(exc, source, entry)
await self._emit(request, source, call_id, started, error=exc) await self._emit(request, source, call_id, started, error=exc)
raise raise
except ResultInvalidError as exc: except ResultInvalidError as exc:
actual, settlement_known = 0, True # 同上, 本版不改这一族记账口径
# 坏结果 ≠ 坏服务: 熔断记成功但不计窗口样本,亦不喂健康分(M2.5 §3.1) # 坏结果 ≠ 坏服务: 熔断记成功但不计窗口样本,亦不喂健康分(M2.5 §3.1)
await self._record_quietly(self._breaker.record_success(entry, count_attempt=False)) await self._record_quietly(self._breaker.record_success(entry, count_attempt=False))
await self._emit(request, source, call_id, started, error=exc) await self._emit(request, source, call_id, started, error=exc)
raise raise
except asyncio.CancelledError: except asyncio.CancelledError:
if not settlement_known:
# 端口已开始、结算未定: 保守保留预扣(宁多扣不凭空退款, 见设计 §6.3 S3)
actual = source.effective_est_tokens()
if entry.is_probe: if entry.is_probe:
await self._record_quietly(self._breaker.release_probe(entry)) await self._record_quietly(self._breaker.release_probe(entry))
await self._emit(request, source, call_id, started, error="cancelled") # 对冲输家在 cancel() 前被编排置位标记(计划 §3.4): 据此区分"被对冲
# 淘汰"与"外部取消",零新遥测列(设计 §4.5);外部取消与对冲取消同时
# 到达的竞速可能误贴——记账方向一致(est 保留),属已批准的可接受残留
label = (
"hedge_cancelled"
if getattr(asyncio.current_task(), _HEDGE_LOSER_ATTR, False)
else "cancelled"
)
await self._emit(request, source, call_id, started, error=label)
raise raise
except (SourceDeadError, TransientError) as exc: except (SourceDeadError, TransientError) as exc:
dead = isinstance(exc, SourceDeadError) dead = isinstance(exc, SourceDeadError)
@@ -330,16 +436,267 @@ class RetryMW:
self._feed_outcome(source.name, ok=False) self._feed_outcome(source.name, ok=False)
if reason == "rate_limited": if reason == "rate_limited":
self._pacer.on_backpressure(source.name) self._pacer.on_backpressure(source.name)
# 结算决定必须在本分支**第一个 await 之前**定死: 同级的 except CancelledError
# 接不住落在本块 await 上的取消,它会直穿 finally——那一刻 actual 是什么就结什么。
# 值与 1.3.5 逐字相同(dead 全额退、瞬时保留预扣),只是算得更早。
actual = 0 if dead else source.effective_est_tokens()
settlement_known = True
await self._record_quietly(self._breaker.record_failure(entry, reason, dead)) await self._record_quietly(self._breaker.record_failure(entry, reason, dead))
if not dead:
# 保守: 失败请求可能已被网关计费(CHS 同款);与入场预扣同源取值
actual = source.effective_est_tokens()
await self._emit(request, source, call_id, started, error=exc) await self._emit(request, source, call_id, started, error=exc)
return _Failed(exc, immediate=dead) return _Failed(exc, immediate=dead)
finally: finally:
self._pacer.leave(source.name) self._pacer.leave(source.name)
await settle_and_release(permit, actual) await settle_and_release(permit, actual)
# —— 对冲编排(issue #24 设计 §4.6;默认关闭,`hedge_after_s is None` 不进这里)——
async def _attempt_hedged(
self,
request: ChatRequest,
picked: tuple[SourceConfig, Permit, GateDecision],
reasons: dict[str, str],
attempt_fails: dict[str, int],
) -> LLMResponse | _Failed:
"""一次"超级尝试": 原路 + (触发后)异源对冲路,FIRST_COMPLETED 竞速。
计时只用事件循环相对时长(`asyncio.wait` timeout),绝不读注入 `now`
(设计 §4.1 时钟纪律);两路各持各的 permit,结算/遥测/熔断写回全部沿用
`_attempt` 既有路径,本方法只做编排与赢家裁定。
"""
# Phase 1 启动原路: 事件与 sink 每轮新建(局部状态,严禁实例属性)
source, permit, entry = picked
first_token: asyncio.Event = asyncio.Event()
# 中段沉默观测(1.3.8): 仅"流式 + stall 阈值已设"的原路携带真事件;
# 其余恒 None = 不观测(对冲路亦恒 None,Phase 4,N3③)
content_event = (
asyncio.Event() if (request.stream and self._stall_after_s is not None) else None
)
sink_p: list[_GenerationSample] = []
primary = asyncio.create_task(
self._attempt(
request,
source,
permit,
entry,
reasons,
attempt_fails,
first_token_event=first_token,
content_event=content_event,
generation_sink=sink_p,
)
)
hedge: asyncio.Task[LLMResponse | _Failed] | None = None
try:
# Phase 2 触发窗: 两阶段(1.3.8)——首 token 前走 AFTER_S,首 token 后
# 走 STALL_AFTER_S;只认 loop 相对时长,绝不读注入钟(设计 §4.1)
if not await self._past_hedge_window(primary, first_token, content_event):
# 原路已了结/首 token 已至: 等价于未配置对冲
outcome = await primary
if isinstance(outcome, LLMResponse):
self._record_generation(request, sink_p)
return outcome
# Phase 3 异源准入(完整 pick 路径,无旁路): 拿不到候选 = 静默等原路
# (对冲是优化不是权利;严禁对这个 None 调 on_no_runnable,见 §3.5)
hedge_picked, _ = await self._admission.pick(
reasons, attempt_fails, exclude=frozenset({source.name})
)
if hedge_picked is None:
outcome = await primary
if isinstance(outcome, LLMResponse):
self._record_generation(request, sink_p)
return outcome
# Phase 3.5 准入后复查: 原路可能在对冲准入的 await 期间已了结——此时
# 一个对冲请求都不发(那是白付一次真实计费请求 + 一份 est 滞留 + 一条
# hedge_cancelled 行)。按既有语义释放刚拿到的对冲准入(probe 须
# release_probe,顺序同 _attempt 取消分支),直接裁定原路结果
if primary.done():
hedge_source, hedge_permit, hedge_entry = hedge_picked
try:
if hedge_entry.is_probe:
await self._record_quietly(self._breaker.release_probe(hedge_entry))
finally:
self._pacer.leave(hedge_source.name)
await settle_and_release(hedge_permit, 0)
outcome = await primary
if isinstance(outcome, LLMResponse):
self._record_generation(request, sink_p)
return outcome
# Phase 4 启动对冲路(v1 单路,H5): 对冲路恒传 first_token_event=None,
# 不再触发梯次对冲;content_event 同恒 None(N3③,v1 不梯次)
sink_h: list[_GenerationSample] = []
hedge = asyncio.create_task(
self._attempt(
request,
*hedge_picked,
reasons,
attempt_fails,
first_token_event=None,
content_event=None,
generation_sink=sink_h,
)
)
# Phase 5 赢家裁定与收口
done, pending = await asyncio.wait(
{primary, hedge}, return_when=asyncio.FIRST_COMPLETED
)
if pending and not any(self._succeeded(t, done) for t in done):
# 先了结的是失败: 等另一路的结论再裁定(它可能后发先至;
# 原路先败、对冲在途时不重试,设计 §4.6)
more, pending = await asyncio.wait(pending)
done |= more
winner = (
primary
if self._succeeded(primary, done)
else hedge
if self._succeeded(hedge, done)
else None
)
if winner is None:
primary_exc = primary.exception()
hedge_exc = hedge.exception() # 两路都取回,不留 never-retrieved 告警
# 非可重试族(RequestRejected/ResultInvalid)穿透,与单路同口径
if primary_exc is not None:
raise primary_exc
if hedge_exc is not None:
raise hedge_exc
# 两败(H6): 汇合为一次失败,重试预算只计一次;路数照登(无赢家)
outcome = _combine_failures(primary.result(), hedge.result())
self._register_hedge(request, hedge_won=False, winner_sink=None)
return outcome
# 两路同时成功的竞速 → 原路优先(保守不弃原路成果),由上面 primary
# 先判实现;竞速落选者已完成,不置标记——它没被取消,行是正常行
loser = hedge if winner is primary else primary
if not loser.done():
# 先置标记再 cancel: _attempt 的取消分支据标记选 hedge_cancelled
setattr(loser, _HEDGE_LOSER_ATTR, True)
loser.cancel()
# 收口: gather 等输家 finally 的结算/遥测跑完才返回(快照含输家,
# attempts==2);对已完成的输家顺带取回结果,不留 never-retrieved 告警
await asyncio.gather(loser, return_exceptions=True)
self._register_hedge(
request,
hedge_won=winner is hedge,
winner_sink=sink_h if winner is hedge else sink_p,
)
return winner.result()
except BaseException:
# 取消穿透与准入冒泡(GovernanceBackendError)同路: 两任务(存在且未完者)
# 同消,尽力收口后原异常上抛;不 shield,收口 await 允许被再取消(136 清理纪律)
tasks = [t for t in (primary, hedge) if t is not None and not t.done()]
for t in tasks:
t.cancel()
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
raise
async def _past_hedge_window(
self,
primary: asyncio.Task,
first_token: asyncio.Event,
content_event: asyncio.Event | None,
) -> bool:
"""对冲触发窗两阶段(1.3.8 设计 §5.2): 首 token 前走 AFTER_S,首 token 后走 STALL_AFTER_S。
只用事件循环相对时长(`asyncio.wait` timeout),绝不读注入 `now`——测试
伪造注入钟跳变不得触发对冲(设计 §4.1)。waiter 任务必须收口,且**不得吞
外部取消**(finally 里 await 已取消的 waiter 会接住一个 CancelledError,
须靠 cancelling() 区分它是 waiter 自己的还是外面打进来的)。
"""
# 阶段 1 首 token 前: AFTER_S 已设才开窗(设计 §5.2);仅 stall 时此阶段不
# 触发,但仍须等首 token 到达才能进阶段 2——否则 pre-TTFT 沉默会被 stall
# 窗误吞(计划 §3.6)
waiter = asyncio.create_task(first_token.wait())
try:
if self._hedge_after_s is not None:
await asyncio.wait(
{primary, waiter},
timeout=self._hedge_after_s,
return_when=asyncio.FIRST_COMPLETED,
)
else:
await asyncio.wait({primary, waiter}, return_when=asyncio.FIRST_COMPLETED)
finally:
waiter.cancel()
try:
await waiter
except asyncio.CancelledError:
if asyncio.current_task().cancelling(): # 外部取消,穿透
raise
if self._hedge_after_s is not None and not primary.done() and not first_token.is_set():
return True # AFTER_S 触发: 阈值到 + 首 token 未至 + 原路在途(1.3.7 原语义)
if primary.done():
return False
# 阶段 2 首 token 后: STALL_AFTER_S 循环等待;非流式/未设 stall → 不开窗
if self._stall_after_s is None or content_event is None:
return False
while not primary.done():
waiter = asyncio.create_task(content_event.wait()) # 每轮自建 waiter(N3④)
try:
await asyncio.wait(
{primary, waiter},
timeout=self._stall_after_s,
return_when=asyncio.FIRST_COMPLETED,
)
finally:
waiter.cancel()
try:
await waiter
except asyncio.CancelledError:
if asyncio.current_task().cancelling(): # 外部取消,穿透
raise
if primary.done():
return False
if content_event.is_set(): # N3① 复检: chunk 与超时同 pass 到达 → 防误触发
content_event.clear() # N3② check→clear 之间无 await
continue
return True # 复检仍未置位 = 真沉默 → 触发对冲
return False
@staticmethod
def _succeeded(task: asyncio.Task, done: set[asyncio.Task]) -> bool:
"""赢家裁定: 已完成、未被取消、无异常且结果为 LLMResponse。"""
return (
task in done
and not task.cancelled()
and task.exception() is None
and isinstance(task.result(), LLMResponse)
)
@staticmethod
def _record_generation(request: ChatRequest, sink: list[_GenerationSample]) -> None:
"""record_generation 的共享守卫: 上下文缺失(库内现场构造)或空 sink 均跳过。"""
if request.call_context is not None and sink:
RetryMW._record_winner_sample(request.call_context, sink[0])
@staticmethod
def _record_winner_sample(context: _CallContext, sample: _GenerationSample) -> None:
"""净口径唯一换算(设计 §4.2): net = max(0, 墙钟 − 沉默),clamp 防御(N4)。
stall 两值透传: None(非流式/增量 chunk<2)时 net=墙钟不退减、快照归因
保持 None(排除条款——None 透传天然达成,无需分支)。
"""
wall_ms, stall_ms, stall_count = sample
net_ms = max(0, wall_ms - (stall_ms or 0))
context.record_generation(
net_ms, accumulate=False, stall_ms=stall_ms, stall_count=stall_count
)
@staticmethod
def _register_hedge(
request: ChatRequest, *, hedge_won: bool, winner_sink: list[_GenerationSample] | None
) -> None:
"""对冲登记(赢家裁定后一次性;同 register_attempt 的 None 守卫)。
hedges 计"实际并发发出的对冲路数"——触发但准入失败的静默不计(没走到
这里);两败轮次无赢家: 路数照登(hedge_won=False),裸生成时间无归属不记。
"""
context = request.call_context
if context is None:
return
if winner_sink:
RetryMW._record_winner_sample(context, winner_sink[0])
context.register_hedge(hedge_won=hedge_won)
async def _on_rejected( async def _on_rejected(
self, exc: RequestRejectedError, source: SourceConfig, entry: GateDecision self, exc: RequestRejectedError, source: SourceConfig, entry: GateDecision
) -> None: ) -> None:
@@ -383,6 +740,9 @@ class RetryMW:
) -> LLMResponse: ) -> LLMResponse:
return LLMResponse( return LLMResponse(
content=result.content, content=result.content,
completion=result.completion,
finish_reason=result.finish_reason,
stream_end=result.stream_end,
thinking=result.thinking, thinking=result.thinking,
model=source.model, model=source.model,
provider=source.provider, provider=source.provider,
@@ -415,11 +775,15 @@ class RetryMW:
*, *,
response: LLMResponse | None = None, response: LLMResponse | None = None,
error: PolyGatewayError | str | None = None, error: PolyGatewayError | str | None = None,
stall_ms: int | None = None,
stall_count: int | None = None,
) -> None: ) -> None:
"""逐次遥测(经注入的单一 Emitter);遥测失败不得影响调用(铁律)。 """逐次遥测(经注入的单一 Emitter);遥测失败不得影响调用(铁律)。
异常**对象原样下传**而非先 `str()` 压平(1.3.5 设计 §5): 状态码、底层异常 异常**对象原样下传**而非先 `str()` 压平(1.3.5 设计 §5): 状态码、底层异常
类型与网关响应体已经在异常上了,在这里压平就是把它们丢掉。 类型与网关响应体已经在异常上了,在这里压平就是把它们丢掉。
stall 两值(1.3.8)只在成功分支由 `_attempt` 显式传入(计划 §3.4);
失败/取消分支恒 None——被取消输家无 TransportResult(设计 §11-B2)。
""" """
if self._emitter is None: if self._emitter is None:
return return
@@ -435,6 +799,8 @@ class RetryMW:
reasoning_applies=True, reasoning_applies=True,
# 公开方法四值之一;本中间件只服务 chat 洋葱 # 公开方法四值之一;本中间件只服务 chat 洋葱
operation="chat", operation="chat",
stall_ms=stall_ms,
stall_count=stall_count,
) )
except asyncio.CancelledError: except asyncio.CancelledError:
raise raise
+38 -1
View File
@@ -216,6 +216,14 @@ class _AttemptUsage:
# 内部字段用枚举类型;裸 str 归一化只发生在 `_record` 下沉 recorder 那一步。 # 内部字段用枚举类型;裸 str 归一化只发生在 `_record` 下沉 recorder 那一步。
# 失败尝试无响应可言,默认 UNKNOWN 本身就是事实("观测不到"),不撒谎 # 失败尝试无响应可言,默认 UNKNOWN 本身就是事实("观测不到"),不撒谎
thinking_observation: ThinkingObservation = ThinkingObservation.UNKNOWN thinking_observation: ThinkingObservation = ThinkingObservation.UNKNOWN
# 1.3.8(issue #27): stall 归因**不从 response 取**——LLMResponse 不增顶层
# 字段(设计 §11-B2 裁决 b),stall 由 retry 层经 emit_attempt 显式 kw 传入;
# 两字段留在此处只为让"尝试视图"形状完整,默认 None 即"无沉默观测"
stall_ms: int | None = None
stall_count: int | None = None
# 1.3.10(#31): 与 stall 相反,结束方式**从 response 取**——LLMResponse 本次有
# 该字段,免去约 50 处 emit_attempt 调用点加参;失败尝试 None = 无流可言
stream_end: str | None = None
@classmethod @classmethod
def of(cls, response: LLMResponse | None) -> _AttemptUsage: def of(cls, response: LLMResponse | None) -> _AttemptUsage:
@@ -234,6 +242,7 @@ class _AttemptUsage:
model_reported=response.model_reported, model_reported=response.model_reported,
reasoning_tokens=response.reasoning_tokens, reasoning_tokens=response.reasoning_tokens,
thinking_observation=response.thinking_observation, thinking_observation=response.thinking_observation,
stream_end=response.stream_end,
) )
@@ -365,7 +374,7 @@ def _assert_recorder_shape(recorder: TelemetryRecorder) -> None:
class TelemetryEmitter: class TelemetryEmitter:
"""从请求与结果组装 36 字段并写入 recorder;一切写失败降级 warning。""" """从请求与结果组装 39 字段并写入 recorder;一切写失败降级 warning。"""
def __init__( def __init__(
self, self,
@@ -405,6 +414,12 @@ class TelemetryEmitter:
reasoning_applies: bool, reasoning_applies: bool,
operation: CallOperation, operation: CallOperation,
class_prefixed_error: bool = False, class_prefixed_error: bool = False,
# 1.3.8(issue #27): 沉默归因由调用点显式传入(成功分支从 TransportResult
# 换算;失败/取消分支不传)。**默认 None 语义安全**: 漏传落 NULL 是合法事实
# ("非流式/无沉默观测"),不是"没表态"——与 reasoning_applies 不同类;
# 默认保住约 50 处既有调用点零改动(计划 §3.4 取舍)
stall_ms: int | None = None,
stall_count: int | None = None,
) -> None: ) -> None:
"""逐次尝试记录(三个 Client 的重试层调用);失败尝试无用量可言,记 0 并标 unavailable。 """逐次尝试记录(三个 Client 的重试层调用);失败尝试无用量可言,记 0 并标 unavailable。
@@ -451,6 +466,9 @@ class TelemetryEmitter:
event_kind="attempt", event_kind="attempt",
attempts=None, attempts=None,
total_latency_ms=None, total_latency_ms=None,
stall_ms=stall_ms,
stall_count=stall_count,
stream_end=usage.stream_end,
) )
async def emit_cache_hit( async def emit_cache_hit(
@@ -498,6 +516,11 @@ class TelemetryEmitter:
event_kind="cache_hit", event_kind="cache_hit",
attempts=None, attempts=None,
total_latency_ms=None, total_latency_ms=None,
# 缓存命中行未产生 transport 观测: 无沉默可言,显式 None
stall_ms=None,
stall_count=None,
# 命中/终态行无流可言(缓存命中已在 CacheMW 置 None,此处显式不信任)
stream_end=None,
) )
async def emit_terminal_failure( async def emit_terminal_failure(
@@ -550,6 +573,11 @@ class TelemetryEmitter:
event_kind="terminal_failure", event_kind="terminal_failure",
attempts=stats.attempts, attempts=stats.attempts,
total_latency_ms=stats.total_latency_ms, total_latency_ms=stats.total_latency_ms,
# 终态行无具体源与 transport 观测: 显式 None
stall_ms=None,
stall_count=None,
# 命中/终态行无流可言(缓存命中已在 CacheMW 置 None,此处显式不信任)
stream_end=None,
) )
async def _record( async def _record(
@@ -592,6 +620,12 @@ class TelemetryEmitter:
event_kind: EventKind, event_kind: EventKind,
attempts: int | None, attempts: int | None,
total_latency_ms: int | None, total_latency_ms: int | None,
# —— 1.3.8: 沉默归因两列(issue #27);各入口已按自己的事实定型,
# 本方法只搬运(同 reasoning_effort 先例)——
stall_ms: int | None,
stall_count: int | None,
# —— 1.3.10: 结束方式(#31);入口已定型,本方法只搬运 ——
stream_end: str | None,
) -> None: ) -> None:
try: try:
# 成本换算(M2 §6): 成功行按单价换算;缓存命中 0.0(未产生新调用); # 成本换算(M2 §6): 成功行按单价换算;缓存命中 0.0(未产生新调用);
@@ -660,6 +694,9 @@ class TelemetryEmitter:
error_body=errors.error_body, error_body=errors.error_body,
attempts=attempts, attempts=attempts,
total_latency_ms=total_latency_ms, total_latency_ms=total_latency_ms,
stall_ms=stall_ms,
stall_count=stall_count,
stream_end=stream_end,
) )
except asyncio.CancelledError: except asyncio.CancelledError:
raise raise
+157 -36
View File
@@ -23,6 +23,7 @@ from typing import TYPE_CHECKING, Any, Literal
from loguru import logger from loguru import logger
from polygateway.client import _aclose_component, _telemetry_status_of from polygateway.client import _aclose_component, _telemetry_status_of
from polygateway.deadline import ensure_call_deadline, with_call_deadline
from polygateway.errors import ( from polygateway.errors import (
AllSourcesExhausted, AllSourcesExhausted,
GovernanceBackendError, GovernanceBackendError,
@@ -38,13 +39,17 @@ from polygateway.middleware.breaker import BreakerGate
from polygateway.middleware.ratelimit import QuotaGate from polygateway.middleware.ratelimit import QuotaGate
from polygateway.middleware.retry import StallClock, _failure_reason, backoff_delay from polygateway.middleware.retry import StallClock, _failure_reason, backoff_delay
from polygateway.middleware.telemetry import TelemetryEmitter, emit_terminal_once from polygateway.middleware.telemetry import TelemetryEmitter, emit_terminal_once
from polygateway.ports import OutcomeAwareSelector from polygateway.ports import OcrDocumentTransport, OutcomeAwareSelector
from polygateway.types import ( from polygateway.types import (
CallStats, CallStats,
ChatRequest, ChatRequest,
LLMResponse, LLMResponse,
OcrDocumentResult,
OcrDocumentTransportResult,
OcrLayoutResult, OcrLayoutResult,
OcrLayoutTransportResult,
OcrTextResult, OcrTextResult,
OcrTextTransportResult,
TelemetryStatus, TelemetryStatus,
Usage, Usage,
_CallContext, _CallContext,
@@ -68,15 +73,18 @@ if TYPE_CHECKING:
from polygateway.types import ( from polygateway.types import (
BackpressurePolicy, BackpressurePolicy,
CallOperation, CallOperation,
OcrLayoutTransportResult,
OcrTextTransportResult,
RetryPolicy, RetryPolicy,
SourceConfig, SourceConfig,
) )
_RESPONSE_TEXT_CAP = 200 # 遥测行 text 截断长度(与 embedding 口径一致) _RESPONSE_TEXT_CAP = 200 # 遥测行 text 截断长度(与 embedding 口径一致)
_OcrKind = Literal["text", "layout"] _OcrKind = Literal["text", "layout", "document"]
# 输入校验文案里的名字。旧两个入口(text/layout)的报错文本保持不变,文档入口自称 document
_PAYLOAD_NAME: dict[_OcrKind, str] = {"text": "image", "layout": "image", "document": "document"}
_TransportResult = OcrTextTransportResult | OcrLayoutTransportResult | OcrDocumentTransportResult
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -87,14 +95,14 @@ class _FailedAttempt:
@dataclass(frozen=True) @dataclass(frozen=True)
class _AttemptOutcome: class _AttemptOutcome:
result: OcrTextTransportResult | OcrLayoutTransportResult result: _TransportResult
source: SourceConfig source: SourceConfig
call_id: str call_id: str
latency_ms: int latency_ms: int
class OcrClient: class OcrClient:
"""治理化 OCR 入口: 同时实现 OcrTextPort 与 OcrLayoutPort(D9 端口族)。 """治理化 OCR 入口: 同时实现 OcrTextPort、OcrLayoutPort 与 OcrDocumentPort(D9 端口族;文档入口见 #34)。
两端点打同一服务实例池,共享同一 scope 的限流/熔断/选源状态; 两端点打同一服务实例池,共享同一 scope 的限流/熔断/选源状态;
与其他 scope 共享后端实例即共享全局闸(显式传入,禁止隐式全局)。 与其他 scope 共享后端实例即共享全局闸(显式传入,禁止隐式全局)。
@@ -115,10 +123,15 @@ class OcrClient:
circuit_open: str = "fail_fast", circuit_open: str = "fail_fast",
telemetry: TelemetryRecorder | None = None, telemetry: TelemetryRecorder | None = None,
text_cap: int | None = None, text_cap: int | None = None,
call_deadline_s: float | None = None,
now: Callable[[], float] = time.monotonic, now: Callable[[], float] = time.monotonic,
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep, sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
rng: Callable[[], float] = random.random, rng: Callable[[], float] = random.random,
) -> None: ) -> None:
# 入口即校: 装配错误当场报,不等到第一次调用才炸
self._call_deadline_s = ensure_call_deadline(
call_deadline_s, "OcrClient(call_deadline_s=...)"
)
self._scope = scope self._scope = scope
# MonkeyOCR 只发 multipart 表单,带 extra_body 的源必须先剥离,否则 # MonkeyOCR 只发 multipart 表单,带 extra_body 的源必须先剥离,否则
# 遥测会记录一个从未发出的采样参数(issue #4 决策 G) # 遥测会记录一个从未发出的采样参数(issue #4 决策 G)
@@ -171,10 +184,14 @@ class OcrClient:
parent_call_id: str | None = None, parent_call_id: str | None = None,
tenant_id: str | None = None, tenant_id: str | None = None,
meta: Mapping[str, Any] | None = None, meta: Mapping[str, Any] | None = None,
call_deadline_s: float | None = None,
) -> OcrTextResult: ) -> OcrTextResult:
"""一次治理文本转录(/ocr/text);text 空串 = 合法"无文字"。 """一次治理文本转录(/ocr/text);text 空串 = 合法"无文字"。
`tenant_id` 与 `meta` 是调用方自定义维度,只进遥测(issue #11)。 `tenant_id` 与 `meta` 是调用方自定义维度,只进遥测(issue #11)。
`call_deadline_s` 是本次调用的墙钟硬边界(issue #22): `None` = 继承装配值。
它治理的是**等待**——到期不等于未产出,返回时刻 = 期限 + 清理耗时。
""" """
# 必须在进链路之前校验: 链路内的一切失败都被遥测层降级成 warning # 必须在进链路之前校验: 链路内的一切失败都被遥测层降级成 warning
# (库铁律「遥测写失败降级不冒泡」),校验放下游等于没有校验 # (库铁律「遥测写失败降级不冒泡」),校验放下游等于没有校验
@@ -189,6 +206,7 @@ class OcrClient:
parent_call_id, parent_call_id,
dimension_tenant_id, dimension_tenant_id,
dimensions, dimensions,
call_deadline_s,
) )
result = outcome.result result = outcome.result
return OcrTextResult( return OcrTextResult(
@@ -209,10 +227,13 @@ class OcrClient:
parent_call_id: str | None = None, parent_call_id: str | None = None,
tenant_id: str | None = None, tenant_id: str | None = None,
meta: Mapping[str, Any] | None = None, meta: Mapping[str, Any] | None = None,
call_deadline_s: float | None = None,
) -> OcrLayoutResult: ) -> OcrLayoutResult:
"""一次治理版面解析(/parse → ZIP);elements 空 = 合法"无元素"。 """一次治理版面解析(/parse → ZIP);elements 空 = 合法"无元素"。
`tenant_id` 与 `meta` 是调用方自定义维度,只进遥测(issue #11)。 `tenant_id` 与 `meta` 是调用方自定义维度,只进遥测(issue #11)。
`call_deadline_s` 同 `recognize_text`(issue #22): `None` = 继承装配值。
""" """
# 校验早于链路,理由同 recognize_text;origin 标明方法名以便定位入口 # 校验早于链路,理由同 recognize_text;origin 标明方法名以便定位入口
dimension_tenant_id, dimensions = validate_caller_dimensions( dimension_tenant_id, dimensions = validate_caller_dimensions(
@@ -226,6 +247,7 @@ class OcrClient:
parent_call_id, parent_call_id,
dimension_tenant_id, dimension_tenant_id,
dimensions, dimensions,
call_deadline_s,
) )
result = outcome.result result = outcome.result
return OcrLayoutResult( return OcrLayoutResult(
@@ -239,6 +261,53 @@ class OcrClient:
call_stats=call_stats, call_stats=call_stats,
) )
async def parse_document(
self,
document: bytes,
*,
session_id: str | None = None,
parent_call_id: str | None = None,
tenant_id: str | None = None,
meta: Mapping[str, Any] | None = None,
call_deadline_s: float | None = None,
) -> OcrDocumentResult:
"""一次治理整份文档解析(/parse → ZIP);PDF 或图片,逐块带类型/页码/bbox/文字。
上传类型由 transport 按内容判定,调用方无需也无法声明。结果的 `markdown` 是服务
生成的整篇原文,含悬空的图片引用;服务端会丢弃脚注、页眉页脚与页码。
`tenant_id` 与 `meta` 是调用方自定义维度,只进遥测(issue #11)。
`call_deadline_s` 同 `recognize_text`(issue #22): `None` = 继承装配值。长文档的
硬上限靠它——源的 `timeout_s` 是逐次网络操作的超时,不是总耗时上界。
"""
# 校验早于链路,理由同 recognize_text;transport 能力校验也在统计边界之外
dimension_tenant_id, dimensions = validate_caller_dimensions(
tenant_id, meta, origin="parse_document(tenant_id=..., meta=...)"
)
self._document_transport()
outcome, call_stats = await self._call(
"document",
"parse_document",
document,
session_id,
parent_call_id,
dimension_tenant_id,
dimensions,
call_deadline_s,
)
result = outcome.result
return OcrDocumentResult(
elements=result.elements,
page_sizes=result.page_sizes,
markdown=result.markdown,
source_name=outcome.source.name,
usage=Usage(0, 0),
latency_ms=outcome.latency_ms,
call_id=outcome.call_id,
raw=result.raw,
call_stats=call_stats,
)
async def check_health(self) -> dict[str, bool]: async def check_health(self) -> dict[str, bool]:
"""逐源并发健康预检(R10);探测失败=False 不上抛,取消穿透。""" """逐源并发健康预检(R10);探测失败=False 不上抛,取消穿透。"""
names = [s.name for s in self._sources] names = [s.name for s in self._sources]
@@ -257,26 +326,38 @@ class OcrClient:
self, self,
kind: _OcrKind, kind: _OcrKind,
operation: CallOperation, operation: CallOperation,
image: bytes, payload: bytes,
session_id: str | None, session_id: str | None,
parent_call_id: str | None, parent_call_id: str | None,
tenant_id: str | None, tenant_id: str | None,
meta: dict[str, Any], meta: dict[str, Any],
call_deadline_s: float | None = None,
) -> tuple[_AttemptOutcome, CallStats]: ) -> tuple[_AttemptOutcome, CallStats]:
if not isinstance(image, bytes): name = _PAYLOAD_NAME[kind]
raise TypeError("image 必须是 bytes(路径读取/批量拼帧留业务侧,D9)") if not isinstance(payload, bytes):
if not image: raise TypeError(f"{name} 必须是 bytes(路径读取/批量拼帧留业务侧,D9)")
raise ValueError("image 不能为空") if not payload:
# M1 例外: `image` 校验在 `_call` 内而非公开方法,故上下文在该校验 raise ValueError(f"{name} 不能为空")
# 期限校验与 `payload` 校验同列(仍在 `_CallContext` 之前、创建 awaitable 之前)
deadline = (
self._call_deadline_s
if call_deadline_s is None
else ensure_call_deadline(call_deadline_s, f"{operation}(call_deadline_s=...)")
)
# M1 例外: `payload` 校验在 `_call` 内而非公开方法,故上下文在该校验
# **通过之后**创建——这样设计 §3 的"校验在统计边界外"对 OCR 才成立 # **通过之后**创建——这样设计 §3 的"校验在统计边界外"对 OCR 才成立
context = _CallContext(now=self._now) context = _CallContext(now=self._now)
try: try:
return await self._run( return await with_call_deadline(
kind, operation, image, session_id, parent_call_id, tenant_id, meta, context self._run(
kind, operation, payload, session_id, parent_call_id, tenant_id, meta, context
),
deadline_s=deadline,
scope=self._scope,
) )
except PolyGatewayError as exc: except PolyGatewayError as exc:
await self._emit_terminal( await self._emit_terminal(
kind, operation, image, session_id, parent_call_id, tenant_id, meta, context, exc kind, operation, payload, session_id, parent_call_id, tenant_id, meta, context, exc
) )
raise raise
except asyncio.CancelledError: except asyncio.CancelledError:
@@ -284,7 +365,7 @@ class OcrClient:
await self._emit_terminal( await self._emit_terminal(
kind, kind,
operation, operation,
image, payload,
session_id, session_id,
parent_call_id, parent_call_id,
tenant_id, tenant_id,
@@ -298,7 +379,7 @@ class OcrClient:
self, self,
kind: _OcrKind, kind: _OcrKind,
operation: CallOperation, operation: CallOperation,
image: bytes, payload: bytes,
session_id: str | None, session_id: str | None,
parent_call_id: str | None, parent_call_id: str | None,
tenant_id: str | None, tenant_id: str | None,
@@ -325,7 +406,7 @@ class OcrClient:
outcome = await self._attempt( outcome = await self._attempt(
kind, kind,
operation, operation,
image, payload,
*picked, *picked,
reasons, reasons,
session_id, session_id,
@@ -351,7 +432,7 @@ class OcrClient:
self, self,
kind: _OcrKind, kind: _OcrKind,
operation: CallOperation, operation: CallOperation,
image: bytes, payload: bytes,
source: SourceConfig, source: SourceConfig,
permit: Permit, permit: Permit,
entry: GateDecision, entry: GateDecision,
@@ -369,15 +450,20 @@ class OcrClient:
# layout 的 POST + ZIP GET 在同一次 `_invoke` 内,故这里只登记 **1** 次 # layout 的 POST + ZIP GET 在同一次 `_invoke` 内,故这里只登记 **1** 次
context.register_attempt() context.register_attempt()
try: try:
result = await self._invoke(kind, image, source, call_id) # 裸生成时间(1.3.7 H8): 计时只包 transport 调用本身(同 RetryMW 口径);
# layout 的 POST + ZIP GET 在同一次 `_invoke` 内,属同一段生成耗时
gen_started = self._now()
result = await self._invoke(kind, payload, source, call_id)
gen_ms = int((self._now() - gen_started) * 1000)
await self._record_quietly(self._breaker.record_success(entry)) await self._record_quietly(self._breaker.record_success(entry))
await self._record_quietly(self._quota.mark_progress()) await self._record_quietly(self._quota.mark_progress())
self._feed_outcome(source.name, ok=True) self._feed_outcome(source.name, ok=True)
latency_ms = int((self._now() - started) * 1000) latency_ms = int((self._now() - started) * 1000)
context.record_generation(gen_ms, accumulate=False)
await self._emit( await self._emit(
kind, kind,
operation, operation,
image, payload,
source, source,
call_id, call_id,
started, started,
@@ -394,7 +480,7 @@ class OcrClient:
await self._emit( await self._emit(
kind, kind,
operation, operation,
image, payload,
source, source,
call_id, call_id,
started, started,
@@ -412,7 +498,7 @@ class OcrClient:
await self._emit( await self._emit(
kind, kind,
operation, operation,
image, payload,
source, source,
call_id, call_id,
started, started,
@@ -433,7 +519,7 @@ class OcrClient:
await self._emit( await self._emit(
kind, kind,
operation, operation,
image, payload,
source, source,
call_id, call_id,
started, started,
@@ -446,14 +532,36 @@ class OcrClient:
) )
return _FailedAttempt(exc, immediate=dead) return _FailedAttempt(exc, immediate=dead)
finally: finally:
# OCR 的 0 token 是**事实**而非"未知"(设计 §6.3 S6): 故取消也恰恰结 0,
# 不引入 chat/embedding 那套 settlement_known 兜底。
await settle_and_release(permit, 0) await settle_and_release(permit, 0)
async def _invoke( async def _invoke(
self, kind: _OcrKind, image: bytes, source: SourceConfig, call_id: str self, kind: _OcrKind, payload: bytes, source: SourceConfig, call_id: str
) -> OcrTextTransportResult | OcrLayoutTransportResult: ) -> _TransportResult:
if kind == "text": if kind == "text":
return await self._transport.recognize_text(image=image, source=source, call_id=call_id) return await self._transport.recognize_text(
return await self._transport.parse_layout(image=image, source=source, call_id=call_id) image=payload, source=source, call_id=call_id
)
if kind == "document":
return await self._document_transport().parse_document(
document=payload, source=source, call_id=call_id
)
return await self._transport.parse_layout(image=payload, source=source, call_id=call_id)
def _document_transport(self) -> OcrDocumentTransport:
"""文档入口所需的 transport 能力校验(唯一的 isinstance 点)。
`OcrDocumentTransport` 独立于 `OcrTransport`: 往旧 Protocol 加成员会让所有现有
实现(测试替身、下游自带 fake)一夜之间不合格。代价是这里必须在运行时校验——
公共入口在进入统计边界前调用一次,让装配错误当场报。
"""
if not isinstance(self._transport, OcrDocumentTransport):
raise TypeError(
"parse_document 需要实现 OcrDocumentTransport.parse_document 的 transport: "
f"{type(self._transport).__name__} 不满足"
)
return self._transport
# —— 辅助(与 embedding 同口径)—— # —— 辅助(与 embedding 同口径)——
@@ -486,7 +594,7 @@ class OcrClient:
def _request_for( def _request_for(
self, self,
kind: _OcrKind, kind: _OcrKind,
image: bytes, payload: bytes,
session_id: str | None, session_id: str | None,
parent_call_id: str | None, parent_call_id: str | None,
tenant_id: str | None, tenant_id: str | None,
@@ -500,7 +608,7 @@ class OcrClient:
维度恒为空、`logical_call_id` 恒为 NULL。 维度恒为空、`logical_call_id` 恒为 NULL。
""" """
return ChatRequest( return ChatRequest(
messages=[{"role": "user", "content": f"<ocr:{kind} image_bytes={len(image)}>"}], messages=[{"role": "user", "content": f"<ocr:{kind} image_bytes={len(payload)}>"}],
session_id=session_id, session_id=session_id,
parent_call_id=parent_call_id, parent_call_id=parent_call_id,
tenant_id=tenant_id, tenant_id=tenant_id,
@@ -512,7 +620,7 @@ class OcrClient:
self, self,
kind: _OcrKind, kind: _OcrKind,
operation: CallOperation, operation: CallOperation,
image: bytes, payload: bytes,
session_id: str | None, session_id: str | None,
parent_call_id: str | None, parent_call_id: str | None,
tenant_id: str | None, tenant_id: str | None,
@@ -524,7 +632,7 @@ class OcrClient:
await emit_terminal_once( await emit_terminal_once(
self._emitter, self._emitter,
request=self._request_for( request=self._request_for(
kind, image, session_id, parent_call_id, tenant_id, meta, context kind, payload, session_id, parent_call_id, tenant_id, meta, context
), ),
context=context, context=context,
error=error, error=error,
@@ -537,7 +645,7 @@ class OcrClient:
self, self,
kind: _OcrKind, kind: _OcrKind,
operation: CallOperation, operation: CallOperation,
image: bytes, payload: bytes,
source: SourceConfig, source: SourceConfig,
call_id: str, call_id: str,
started: float, started: float,
@@ -546,14 +654,14 @@ class OcrClient:
tenant_id: str | None, tenant_id: str | None,
meta: dict[str, Any], meta: dict[str, Any],
context: _CallContext, context: _CallContext,
result: OcrTextTransportResult | OcrLayoutTransportResult | None = None, result: _TransportResult | None = None,
error: PolyGatewayError | str | None = None, error: PolyGatewayError | str | None = None,
) -> None: ) -> None:
"""逐尝试遥测(单一 Emitter): messages 占位摘要,图像 bytes 绝不入库。""" """逐尝试遥测(单一 Emitter): messages 占位摘要,图像 bytes 绝不入库。"""
if self._emitter is None: if self._emitter is None:
return return
request = self._request_for( request = self._request_for(
kind, image, session_id, parent_call_id, tenant_id, meta, context kind, payload, session_id, parent_call_id, tenant_id, meta, context
) )
latency_ms = int((self._now() - started) * 1000) latency_ms = int((self._now() - started) * 1000)
response = None response = None
@@ -589,7 +697,19 @@ class OcrClient:
) )
@staticmethod @staticmethod
def _summarize(result: OcrTextTransportResult | OcrLayoutTransportResult) -> str: def _summarize(result: _TransportResult) -> str:
"""遥测行 response 摘要。**永不写入文档正文**:文档只记规模(元素数/页数/md 字数)。
**按结构分派而不是 isinstance**: 旧实现就是 `hasattr(result, "text")`,下游测试替身
可能返回非库类型的鸭子对象,换成 isinstance 会让它们在开启遥测后以原生
AttributeError 漏出。顺序要紧:文档结果有 `markdown`,先于只看 `text` 的判定,否则
会被静默套成 layout 摘要而漏记 `md_chars`。
"""
if hasattr(result, "markdown"):
return (
f"<document elements={len(result.elements)} pages={len(result.page_sizes)} "
f"md_chars={len(result.markdown)}>"
)
if hasattr(result, "text"): if hasattr(result, "text"):
return result.text[:_RESPONSE_TEXT_CAP] return result.text[:_RESPONSE_TEXT_CAP]
return f"<elements n={len(result.elements)} pages={len(result.page_sizes)}>" return f"<elements n={len(result.elements)} pages={len(result.page_sizes)}>"
@@ -668,6 +788,7 @@ class OcrClient:
# OCR 行与 chat 行写同一张 llm_calls;漏传这一条,同表内就一半受控 # OCR 行与 chat 行写同一张 llm_calls;漏传这一条,同表内就一半受控
# 一半不受控(issue #12) # 一半不受控(issue #12)
text_cap=gw.telemetry_text_cap, text_cap=gw.telemetry_text_cap,
call_deadline_s=gw.call_deadline_s,
) )
_mark_owned_components(client, limiter=limiter, breaker=breaker, telemetry=telemetry) _mark_owned_components(client, limiter=limiter, breaker=breaker, telemetry=telemetry)
return client return client
+45 -1
View File
@@ -5,6 +5,7 @@
时间量纲一律**秒**(CHS Redis 实现内部的毫秒换算是后端私事,不进契约)。 时间量纲一律**秒**(CHS Redis 实现内部的毫秒换算是后端私事,不进契约)。
""" """
import asyncio
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from dataclasses import dataclass from dataclasses import dataclass
from enum import StrEnum from enum import StrEnum
@@ -15,10 +16,13 @@ from .types import (
Effort, Effort,
EmbeddingTransportResult, EmbeddingTransportResult,
LLMResponse, LLMResponse,
OcrDocumentResult,
OcrDocumentTransportResult,
OcrLayoutResult, OcrLayoutResult,
OcrLayoutTransportResult, OcrLayoutTransportResult,
OcrTextResult, OcrTextResult,
OcrTextTransportResult, OcrTextTransportResult,
ResponseMode,
SourceConfig, SourceConfig,
SourceStats, SourceStats,
TelemetryStatus, TelemetryStatus,
@@ -39,6 +43,10 @@ class Middleware(Protocol):
class Transport(Protocol): class Transport(Protocol):
"""一次原始调用的协议细节(请求组装/流式解析/错误翻译);不含任何治理。 """一次原始调用的协议细节(请求组装/流式解析/错误翻译);不含任何治理。
`response_mode` 为必填返回契约:openai 成功必须提供 completion,
不支持者应 RequestRejected,不能静默退回文本。工具的实际增量与首次身份
同正文/推理共用活性事件;同帧只置位一次,重复身份不算进展。
`reasoning_effort` 是本次调用要求的推理档位(`None` = 不表态,随源级配置)。 `reasoning_effort` 是本次调用要求的推理档位(`None` = 不表态,随源级配置)。
它必须走**协议参数**而不能让 transport 自己去读 `ChatRequest`: 端口只收拆开的 它必须走**协议参数**而不能让 transport 自己去读 `ChatRequest`: 端口只收拆开的
请求要素,是为了让 transport 不依赖洋葱内部的请求类型(P7 端口最内层)。 请求要素,是为了让 transport 不依赖洋葱内部的请求类型(P7 端口最内层)。
@@ -46,6 +54,16 @@ class Transport(Protocol):
该参数**不设默认值**,与 `TelemetryRecorder.record_llm_call` 同一既有约定: 该参数**不设默认值**,与 `TelemetryRecorder.record_llm_call` 同一既有约定:
库外无第三方实现者,写全签名的成本为零,而默认值会把"某一层漏传"变成静默的 库外无第三方实现者,写全签名的成本为零,而默认值会把"某一层漏传"变成静默的
"调用方没表态"——一次本该报错的漏配就此变成一次悄悄涨价的调用。 "调用方没表态"——一次本该报错的漏配就此变成一次悄悄涨价的调用。
`first_token_event` 同一约定(1.3.7 对冲 H2): `None` = 调用方不观测首 token
(未启用对冲);非流式实现**永不置位**(物理上无中途信号,事件自然退化为纯时间
阈值),流式实现在首个增量(内容或思考)到达时置位。
`content_event` 同一约定(1.3.8 中段沉默对冲,issue #27/#28): `None` = 调用方
不观测增量 chunk(未启用中段对冲);**流式**实现每到达一个增量 chunk(`content`
或 `reasoning_content`,与看门狗/首 token 活性口径一致)置位一次;空 chunk、
usage 帧、SSE keepalive 不算;非流式实现**永不置位**;对冲路恒收 `None`
(v1 不梯次,设计 §5.2 N3③)。
""" """
async def complete( async def complete(
@@ -57,6 +75,9 @@ class Transport(Protocol):
overlay: dict[str, Any], overlay: dict[str, Any],
call_id: str, call_id: str,
reasoning_effort: Effort | None, reasoning_effort: Effort | None,
first_token_event: asyncio.Event | None,
content_event: asyncio.Event | None,
response_mode: ResponseMode,
) -> TransportResult: ... ) -> TransportResult: ...
@@ -83,6 +104,26 @@ class OcrLayoutPort(Protocol):
async def parse_layout(self, image: bytes) -> OcrLayoutResult: ... async def parse_layout(self, image: bytes) -> OcrLayoutResult: ...
@runtime_checkable
class OcrDocumentPort(Protocol):
"""OCR 整份文档解析端口(#34;对应 MonkeyOCR POST /parse → GET ZIP,PDF 或图片)。"""
async def parse_document(self, document: bytes) -> OcrDocumentResult: ...
@runtime_checkable
class OcrDocumentTransport(Protocol):
"""整份文档解析的协议细节(#34 设计 §4.2);不含任何治理。
**独立于 `OcrTransport`**: 往那个 `runtime_checkable` Protocol 加成员,会让所有
现有实现(测试替身、下游自带 fake)在 `isinstance` 上一夜之间不合格。
"""
async def parse_document(
self, *, document: bytes, source: SourceConfig, call_id: str
) -> OcrDocumentTransportResult: ...
@runtime_checkable @runtime_checkable
class OcrTransport(Protocol): class OcrTransport(Protocol):
"""一次原始 OCR 调用的协议细节(M3 设计 §3.2);不含任何治理。 """一次原始 OCR 调用的协议细节(M3 设计 §3.2);不含任何治理。
@@ -271,7 +312,7 @@ class TelemetryStatusProvider(Protocol):
@runtime_checkable @runtime_checkable
class TelemetryRecorder(Protocol): class TelemetryRecorder(Protocol):
"""遥测后端;36 字段冻结(M1 设计 §4.4 + issue #3/#4/#11/#16/#20 + 1.3.5),唯一调用点是 TelemetryEmitter。 """遥测后端;39 字段冻结(M1 设计 §4.4 + issue #3/#4/#11/#16/#20 + 1.3.5/1.3.8/1.3.10),唯一调用点是 TelemetryEmitter。
新增参数不设默认值: 库外无第三方实现者(三项目迁移时删除了各自的同名 新增参数不设默认值: 库外无第三方实现者(三项目迁移时删除了各自的同名
Protocol),完整签名的成本为零,而少写一列会被 emitter 的降级吞成 warning。 Protocol),完整签名的成本为零,而少写一列会被 emitter 的降级吞成 warning。
@@ -336,4 +377,7 @@ class TelemetryRecorder(Protocol):
error_body: str | None, error_body: str | None,
attempts: int | None, attempts: int | None,
total_latency_ms: int | None, total_latency_ms: int | None,
stall_ms: int | None,
stall_count: int | None,
stream_end: str | None,
) -> None: ... ) -> None: ...
+26 -4
View File
@@ -5,8 +5,8 @@
多处各存一份必然漂移,而漂移的表现是"下游照打印的 SQL 建完表,库仍报缺列"。 多处各存一份必然漂移,而漂移的表现是"下游照打印的 SQL 建完表,库仍报缺列"。
**`COLUMNS` 是 INSERT 字段序,不是物理列序**: 数据库自填的 `created_at` 不在其中(它带 **`COLUMNS` 是 INSERT 字段序,不是物理列序**: 数据库自填的 `created_at` 不在其中(它带
`DEFAULT now()` / `datetime('now')`,库从不显式写它)。物理表列 = 36 个 INSERT 字段 + `DEFAULT now()` / `datetime('now')`,库从不显式写它)。物理表列 = 39 个 INSERT 字段 +
`created_at` = 37;列数断言一律按物理列数写,两套口径混用是最易错处。 `created_at` = 40;列数断言一律按物理列数写,两套口径混用是最易错处。
本模块只依赖标准库: `telemetry/` 与 `backends/`、`transports/`、`structured/` 同层且 本模块只依赖标准库: `telemetry/` 与 `backends/`、`transports/`、`structured/` 同层且
互不依赖(import-linter 契约执法)。 互不依赖(import-linter 契约执法)。
@@ -62,7 +62,10 @@ CREATE TABLE IF NOT EXISTS llm_calls (
cause_type TEXT, cause_type TEXT,
error_body TEXT, error_body TEXT,
attempts INTEGER, attempts INTEGER,
total_latency_ms INTEGER total_latency_ms INTEGER,
stall_ms INTEGER,
stall_count INTEGER,
stream_end TEXT
); );
""" """
@@ -104,7 +107,10 @@ CREATE TABLE IF NOT EXISTS llm_calls (
cause_type TEXT, cause_type TEXT,
error_body TEXT, error_body TEXT,
attempts INTEGER, attempts INTEGER,
total_latency_ms INTEGER total_latency_ms INTEGER,
stall_ms INTEGER,
stall_count INTEGER,
stream_end TEXT
); );
""" """
@@ -137,6 +143,12 @@ SQLITE_BACKFILL = (
("error_body", "TEXT"), ("error_body", "TEXT"),
("attempts", "INTEGER"), ("attempts", "INTEGER"),
("total_latency_ms", "INTEGER"), ("total_latency_ms", "INTEGER"),
# 1.3.8 沉默归因两列(issue #27): 同样可空无默认——旧行 NULL = "补列之前
# 没记过这件事",与"观测过且零沉默"(0)是两回事
("stall_ms", "INTEGER"),
("stall_count", "INTEGER"),
# 1.3.10 结束方式(#31): 可空无默认,旧行 NULL = 补列之前没记过;值域见 StreamEnd
("stream_end", "TEXT"),
) )
# PG 补列的列定义。语句由此派生成两份文本(见下),使"库内执行的那份"与"打印给 # PG 补列的列定义。语句由此派生成两份文本(见下),使"库内执行的那份"与"打印给
@@ -163,6 +175,11 @@ _PG_BACKFILL_DECLS = (
("error_body", "TEXT"), ("error_body", "TEXT"),
("attempts", "INTEGER"), ("attempts", "INTEGER"),
("total_latency_ms", "INTEGER"), ("total_latency_ms", "INTEGER"),
# 1.3.8 两列,列序与 SQLITE_BACKFILL 逐项对齐(理由同上)
("stall_ms", "INTEGER"),
("stall_count", "INTEGER"),
# 1.3.10 结束方式(#31): 可空无默认,旧行 NULL = 补列之前没记过;值域见 StreamEnd
("stream_end", "TEXT"),
) )
# 新列排在 created_at 之后: 与旧表 ALTER 追加的位置一致(见 SQLITE_BACKFILL 同款注释)。 # 新列排在 created_at 之后: 与旧表 ALTER 追加的位置一致(见 SQLITE_BACKFILL 同款注释)。
@@ -212,6 +229,11 @@ COLUMNS = (
"error_body", "error_body",
"attempts", "attempts",
"total_latency_ms", "total_latency_ms",
# —— 1.3.8 沉默归因(issue #27)——
"stall_ms",
"stall_count",
# —— 1.3.10 结束方式(#31)——
"stream_end",
) )
_COLUMN_SET = frozenset(COLUMNS) _COLUMN_SET = frozenset(COLUMNS)
+1 -1
View File
@@ -143,7 +143,7 @@ class SQLiteRecorder:
logger.warning("SQLite 遥测补列失败(写入将逐行降级): {}", exc) logger.warning("SQLite 遥测补列失败(写入将逐行降级): {}", exc)
async def record_llm_call(self, **fields: object) -> None: async def record_llm_call(self, **fields: object) -> None:
"""写一行遥测;字段集合即 36 字段冻结签名(ports.TelemetryRecorder)。 """写一行遥测;字段集合即 39 字段冻结签名(ports.TelemetryRecorder)。
取值按 `self._columns`(manual 档可能已被裁剪),与 `self._insert` 的 取值按 `self._columns`(manual 档可能已被裁剪),与 `self._insert` 的
占位符同序——两者必须一起改,分开改就是把值写进错位的列。 占位符同序——两者必须一起改,分开改就是把值写进错位的列。
@@ -0,0 +1,328 @@
"""MonkeyOCR 结果包解析: bytes → 文档元素的纯函数(#34 设计 §5/§5.1)。
本模块**无 HTTP、无治理、无全局状态**,所以能在 `asyncio.to_thread` 里运行——
同步解析大对象会占住事件循环,使取消与 `call_deadline_s`(基于 `asyncio.timeout`)
无法及时生效。
内容只取自 `_middle.json`(bbox/类型/页码/阅读序的权威来源,spans 里直接带
`content`/`html`)与配对的 `.md`。**有意不读 `_content_list.json`**: 它是展示格式,
文本里 `* \\` ~ $` 会被转义成 `\\$` 之类(实测 134 个 text/title 块中 23 个含 `\\$`,
取它会污染 LaTeX),且无 bbox、类型词汇与 middle 不同,逐块对位还会引入版本漂移
时整份失败的新故障面。
与 layout 路径(`monkey_ocr._parse_middle_json`,既有调用方在用,保持原样)的三处差异:
零页拒绝(成功解析的文档至少 1 页)、`page_idx` 必须等于页序(layout 路径回退枚举
下标,会让 page_sizes 按 page_index 索引的契约静默错位)、bbox 只要求有限且有序
(整数化退化校验专为 CHS shim 的 `int()` 裁剪,文档消费者不裁剪,一个退化框不该
让数百个块的整份文档失败)。
"""
from __future__ import annotations
import json
import lzma
import math
import zipfile
import zlib
from io import BytesIO
from typing import TYPE_CHECKING
from polygateway.errors import ResultInvalidError
from polygateway.types import OcrDocumentElement
if TYPE_CHECKING:
from collections.abc import Callable
MAX_MIDDLE_BYTES = 64 * 1024 * 1024
"""`_middle.json` 声明大小上限。样本约 72KB/页,64MiB 足够数百页;超限即拒,
防止一个畸形/恶意包把解析线程的内存吃满。"""
MAX_MARKDOWN_BYTES = 16 * 1024 * 1024
"""`.md` 声明大小上限(样本约 3KB/页)。"""
_MIDDLE_SUFFIX = "_middle.json"
# ZIP 层「外部数据不可读」的标准库约定用法。只在 ZIP I/O 区域(构造 + 读取)内捕获,
# 不放到外层边界——宽泛捕获 RuntimeError/OSError 会掩盖编程错误:
# 加密成员 → RuntimeError;未知压缩方法/版本字段损坏 → NotImplementedError;
# DEFLATE 流损坏 → zlib.error / EOFError;BZIP2 流损坏 → OSError;LZMA 流损坏 → LZMAError
_ZIP_IO_ERRORS = (RuntimeError, NotImplementedError, zlib.error, EOFError, OSError, lzma.LZMAError)
def finite_number(value: object) -> float:
"""JSON 数值 → 有限浮点;布尔与非有限数拒绝(CHS invokers.py:427 逐字)。"""
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValueError("字段不是数值")
number = float(value)
if not math.isfinite(number):
raise ValueError("字段不是有限数值")
return number
def parse_page_size(page: dict) -> tuple[float, float]:
"""`page_size` → (宽, 高);须为 2 个正有限数(CHS invokers.py 数值防御,原样迁入)。"""
page_size = page.get("page_size")
if not isinstance(page_size, list) or len(page_size) != 2:
raise ValueError("page_size 无效")
width, height = (finite_number(item) for item in page_size)
if width <= 0 or height <= 0:
raise ValueError("page_size 必须为正数")
return (width, height)
def parse_document_package(
zip_bytes: bytes,
) -> tuple[list[OcrDocumentElement], list[tuple[float, float]], str]:
"""ZIP → (elements, page_sizes, markdown);一切形态异常归 `ResultInvalidError`。
与 layout 路径的三处差异(理由见模块 docstring): ① 零页拒绝(成功解析的文档至少 1 页);
② `page_idx` 必须等于页序,不回退枚举下标;③ bbox 只要求有限且有序,不做整数化退化校验。
外层异常边界**同时盖住「读取」与「全部结构解析」**: `finite_number` 里
`float(10**400)` 会抛 `OverflowError`,它发生在读取之后。放错位置的后果是原生
异常漏出——`OcrClient._call` 只接 `PolyGatewayError`,于是既不写终态遥测行,又让
持有的半开探针不被归还。`CancelledError` 是 `BaseException`,天然不在其内。
"""
try:
payload, markdown = _read_members(zip_bytes)
elements, page_sizes = _parse_pages(payload)
except ResultInvalidError:
raise
except (
ValueError, # 含 JSONDecodeError / UnicodeDecodeError
TypeError,
KeyError,
OverflowError,
RecursionError,
zipfile.BadZipFile,
zipfile.LargeZipFile,
) as exc:
raise ResultInvalidError(f"OCR 文档结果包内容无效: {exc}") from exc
return elements, page_sizes, markdown
def _read_members(zip_bytes: bytes) -> tuple[object, str]:
"""取首个 `*_middle.json` 与按名配对的 `.md`,返回 (middle 解码对象, md 文本)。
`ZipFile(...)` 的构造与成员读取在**同一个**窄 `try` 里: 独立验证发现中央目录版本字段
损坏会在构造阶段抛 `NotImplementedError`,而旧写法只包了成员读取。
"""
try:
with zipfile.ZipFile(BytesIO(zip_bytes)) as archive:
middle_raw, markdown_raw = _read_pair(archive)
except _ZIP_IO_ERRORS as exc:
raise ResultInvalidError(f"OCR 文档结果包无法读取: {exc}") from exc
return json.loads(middle_raw), markdown_raw.decode("utf-8-sig")
def _read_pair(archive: zipfile.ZipFile) -> tuple[bytes, bytes]:
"""定位并读取 middle 与配对的 md;缺失或超限抛 `ResultInvalidError`。"""
names = archive.namelist()
middle_names = [n for n in names if n.endswith(_MIDDLE_SUFFIX)]
if not middle_names:
raise ResultInvalidError("OCR 文档结果包缺少 _middle.json")
middle_name = middle_names[0]
# 精确配对而非"取第一个 .md": 包里可能有多个 .md,取错就是把别的文档当本文档
markdown_name = middle_name[: -len(_MIDDLE_SUFFIX)] + ".md"
if markdown_name not in names:
raise ResultInvalidError(f"OCR 文档结果包缺少与 {middle_name} 配对的 {markdown_name}")
return (
_read_member(archive, middle_name, MAX_MIDDLE_BYTES),
_read_member(archive, markdown_name, MAX_MARKDOWN_BYTES),
)
def _read_member(archive: zipfile.ZipFile, name: str, cap: int) -> bytes:
"""读一个成员:先查**声明大小**再解压。
`zipfile` 读取不会超过声明大小(超出即 CRC 失败),所以声明大小检查足以界定内存。
解压异常由调用方 `_read_members` 的窄 `try` 统一转换。
"""
info = archive.getinfo(name)
if info.file_size > cap:
raise ResultInvalidError(
f"OCR 文档结果包成员 {name} 声明大小 {info.file_size} 超过上限 {cap}"
)
with archive.open(info) as stream:
return stream.read()
def _parse_pages(
payload: object,
) -> tuple[list[OcrDocumentElement], list[tuple[float, float]]]:
"""`pdf_info` → 全部页的元素与页尺寸;零页拒绝。"""
if not isinstance(payload, dict) or not isinstance(payload.get("pdf_info"), list):
raise ValueError("_middle.json 缺少 pdf_info 列表")
pages = payload["pdf_info"]
if not pages:
raise ResultInvalidError("OCR 文档结果包 pdf_info 为空: 成功解析的文档至少应有 1 页")
elements: list[OcrDocumentElement] = []
page_sizes: list[tuple[float, float]] = []
for position, page in enumerate(pages):
page_elements, page_size = _parse_page(page, position)
elements.extend(page_elements)
page_sizes.append(page_size)
return elements, page_sizes
def _parse_page(
page: object, position: int
) -> tuple[list[OcrDocumentElement], tuple[float, float]]:
"""单页 → (该页元素, 页尺寸);`para_blocks` 缺失按空页处理。"""
if not isinstance(page, dict):
raise ValueError("页面结构无效")
page_size = parse_page_size(page)
page_index = _page_index(page, position)
blocks = page.get("para_blocks", []) # 缺失 = 空页(空白页/无可识别块)是合法"无内容"
if not isinstance(blocks, list):
raise ValueError("para_blocks 无效")
return [e for block in blocks for e in _block_elements(block, page_index)], page_size
def _page_index(page: dict, position: int) -> int:
"""`page_idx` 必须是等于页序的整数(非 bool)。
不沿用 layout 路径的"非法则回退枚举下标": 回退会掩盖服务端跳页/乱序,而
`page_sizes` 是按 page_index 索引的,静默错位比响亮失败更糟。
"""
raw = page.get("page_idx")
if isinstance(raw, bool) or not isinstance(raw, int) or raw != position:
raise ValueError(f"page_idx 无效或与页序不符: {raw!r} != {position}")
return raw
def _block_type(block: object) -> str:
"""校验块是 dict 且 `type` 为非空白字符串,返回 type。"""
if not isinstance(block, dict):
raise ValueError("块结构无效")
block_type = block.get("type")
if not isinstance(block_type, str) or not block_type.strip():
raise ValueError("块缺少 type")
return block_type
def _loose_bbox(raw: object) -> tuple[float, float, float, float]:
"""长度 4 的有限数值且有序(允许零宽/零高);差异理由见模块 docstring。"""
if not isinstance(raw, list) or len(raw) != 4:
raise ValueError("bbox 无效")
x1, y1, x2, y2 = (finite_number(item) for item in raw)
if x2 < x1 or y2 < y1:
raise ValueError("bbox 边界顺序无效")
return (x1, y1, x2, y2)
def _sub_blocks(block: dict) -> list[dict]:
"""嵌套子块列表(缺失为空);子块须是 dict 且 type 合法。"""
subs = block.get("blocks", [])
if not isinstance(subs, list):
raise ValueError("blocks 无效")
for sub in subs:
_block_type(sub) # 顺带校验子块是 dict 且 type 合法
return subs
def _is_body(sub: dict) -> bool:
"""子块是否正文部分(type 以 `_body` 结尾,如 table_body / image_body)。"""
return sub["type"].endswith("_body")
def _block_elements(block: object, page_index: int) -> list[OcrDocumentElement]:
"""父块 → 元素,外加其非 `*_body` 的嵌套子块(紧随父块、保持服务顺序)。"""
block_type = _block_type(block)
assert isinstance(block, dict) # _block_type 已校验;仅为类型收窄(内部不变量)
parent = OcrDocumentElement(
type=block_type,
bbox=_loose_bbox(block.get("bbox")),
page_index=page_index,
text=_block_text(block_type, block),
)
return [parent, *_extra_elements(block, page_index)]
def _extra_elements(block: dict, page_index: int) -> list[OcrDocumentElement]:
"""caption/footnote 一类子块:默认部署不产生,出现时有文字就独立成元素,不静默丢弃。"""
extras: list[OcrDocumentElement] = []
for sub in _sub_blocks(block):
if _is_body(sub):
continue
text = _lines_text(sub.get("lines", []))
if text:
extras.append(
OcrDocumentElement(
type=sub["type"],
bbox=_loose_bbox(sub.get("bbox")),
page_index=page_index,
text=text,
)
)
return extras
_TEXT_BEARING_TYPES = frozenset({"text", "title", "interline_equation"})
"""必带 `lines` 的块类型。缺 `lines` 不能当作"空文本"——与 table 缺内容就报错同口径,
否则下游分不清"服务识别出空块"和"服务给了坏结构"。其余未知类型无 lines 仍是合法空文本。"""
def _block_text(block_type: str, block: dict) -> str:
"""块 → 文字;随 type 分派(table 取 html/latex,image 为空串,其余取 lines)。"""
if block_type == "table":
return _table_text(_sub_blocks(block))
if block_type == "image":
return "" # 图内文字服务不识别;span 只有 image_path
if block_type in _TEXT_BEARING_TYPES and "lines" not in block:
raise ValueError(f"{block_type} 块缺少 lines")
if "lines" in block:
return _lines_text(block["lines"])
# 未知容器类型: 其 `*_body` 子块的文字并入父元素(非 body 子块已由 _extra_elements 输出)
return "\n".join(
_lines_text(sub.get("lines", [])) for sub in _sub_blocks(block) if _is_body(sub)
)
def _table_text(subs: list[dict]) -> str:
"""table 块 → 其 `table_body` 子块的 HTML(或 latex);没有表体即畸形。"""
bodies = [sub for sub in subs if sub["type"] == "table_body"]
if not bodies:
raise ValueError("table 块缺少 table_body 子块")
return "\n".join(_lines_text(body.get("lines"), _table_span_text) for body in bodies)
def _table_span_text(span: dict) -> str:
"""表体 span 取 html;上游另有 latex 分支。皆无则响亮失败——表格"无内容"与
合法空串在结果里无法区分,静默补空会让下游把缺失当成空表。"""
for key in ("html", "latex"):
value = span.get(key)
if isinstance(value, str):
return value
raise ValueError("table span 缺少 html/latex")
def _lines_text(lines: object, span_text: Callable[[dict], str] | None = None) -> str:
"""行内 span 按 "" 拼、行间按 "\\n" 拼。
样本全是 1 行 1 span;多行/多 span 路径无真实样本,拼接规则由构造 fixture 钉住,
文档注明"未经实测"。
"""
if not isinstance(lines, list):
raise ValueError("lines 无效")
return "\n".join(_line_text(line, span_text or _span_text) for line in lines)
def _line_text(line: object, span_text: Callable[[dict], str]) -> str:
"""一行 → 文字:该行全部 span 的文字直接相接。"""
if not isinstance(line, dict) or not isinstance(line.get("spans"), list):
raise ValueError("行结构无效")
parts = []
for span in line["spans"]:
if not isinstance(span, dict):
raise ValueError("span 结构无效")
parts.append(span_text(span))
return "".join(parts)
def _span_text(span: dict) -> str:
"""文本 span 取 `content`(必须是 str);`inline_equation` 包 `$…$`(上游惯例)。"""
content = span.get("content")
if not isinstance(content, str):
raise ValueError("span 缺少字符串 content")
return f"${content}$" if span.get("type") == "inline_equation" else content
@@ -0,0 +1,455 @@
"""OpenAI 单 choice 消息聚合:仅协议值处理,不做 I/O 或治理。
1.3.10(#30): null 一律视为缺省;已知字段沿用 1.3.9 语义;未知字段按 OpenAI 流式
累加惯例无损合并,冲突删键并登记路径——方言是确定性的,拒绝只会让重试重演同一失败。
"""
import copy
from dataclasses import dataclass
from typing import Any
from polygateway.errors import TransientError
from polygateway.types import OpenAIChatCompletion, ResponseMode
_DELTA_KNOWN = frozenset({"role", "content", "reasoning_content", "tool_calls"})
_TOOL_KNOWN = frozenset({"index", "id", "type", "function"})
_FUNCTION_KNOWN = frozenset({"name", "arguments"})
# 标签键: 每帧可能重复下发的身份/格式标记,同值幂等,绝不拼接(拼成 "x-v1x-v1"
# 回传上游会被拒);也不算生成进展
_LABEL_KEYS = frozenset({"index", "type", "id", "format"})
def _invalid(reason: str) -> TransientError:
"""协议形态失败,不回显可能敏感的工具参数。"""
return TransientError(f"OpenAI 协议失败: {reason}", operation="chat")
def _object(value: Any, name: str) -> dict[str, Any]:
"""外部对象先校验再读取。"""
if not isinstance(value, dict):
raise _invalid(f"{name} 必须为对象")
return value
def _identity(target: dict[str, Any], key: str, value: Any) -> bool:
"""身份字段只接受同值重复,不把重复完整 ID 当分片拼接。"""
if value is None:
return False
if not isinstance(value, str):
raise _invalid(f"{key} 必须为字符串")
if key in target and target[key] != value:
raise _invalid(f"{key} 冲突")
changed = key not in target and bool(value)
target[key] = value
return changed
def _append(target: dict[str, Any], key: str, value: Any) -> str:
"""字符串片段逐字拼接,保留缺省/null/空串区别。"""
if value is None:
target.setdefault(key, None)
return ""
if not isinstance(value, str):
raise _invalid(f"{key} 必须为字符串或 null")
target[key] = (target.get(key) or "") + value
return value
def _assemble(message: dict[str, Any], tools: dict[int, dict[str, Any]]) -> dict[str, Any]:
"""按 index 排序组装工具;`finish()` 与 `raw_message()` 共用,判定与快照同源。"""
assembled = copy.deepcopy(message)
if tools:
assembled["tool_calls"] = [copy.deepcopy(tools[index]) for index in sorted(tools)]
return assembled
def _completion(message: dict[str, Any], metadata: dict[str, Any]) -> OpenAIChatCompletion:
"""复用内核最终快照校验并翻译领域错误。"""
try:
return OpenAIChatCompletion(
message, metadata.get("finish_reason"), metadata.get("id"), metadata.get("usage")
)
except (ValueError, TypeError) as exc:
raise _invalid(str(exc)) from exc
# —— 未知字段合并(设计 §5.2) ——
class _MergeConflictError(Exception):
"""合并冲突(内部控制流): 由 `_ExtensionMerger.merge_key` 就地收口为删键。"""
def _is_indexed(items: list[Any]) -> bool:
"""数组元素(忽略 null)均为带整数 index 的对象。"""
present = [item for item in items if item is not None]
return bool(present) and all(
isinstance(item, dict) and type(item.get("index")) is int for item in present
)
def _fragment_progress(key: str, value: Any) -> bool:
"""不落盘的活性判据(文本模式): 非标签键上的非空字符串片段。"""
if isinstance(value, str):
return key not in _LABEL_KEYS and value != ""
if isinstance(value, dict):
return any(_fragment_progress(k, v) for k, v in value.items())
if isinstance(value, list):
return any(_fragment_progress(key, item) for item in value)
return False
class _ExtensionMerger:
"""每尝试私有的未知字段合并器;冲突路径即封禁,后续同路径值丢弃。"""
def __init__(self) -> None:
self.conflicts: set[str] = set()
self._array_modes: dict[str, bool] = {}
def merge_key(self, target: dict[str, Any], key: str, value: Any, path: str) -> bool:
"""合并一个键;返回是否并入了非空字符串片段(活性)。"""
if path in self.conflicts:
return False
if value is None:
target.setdefault(key, None)
return False
try:
merged, grew = self._merge(target.get(key), value, key, path)
except _MergeConflictError:
target.pop(key, None)
self.conflicts.add(path)
return False
target[key] = merged
return grew
def _merge(self, old: Any, new: Any, key: str, path: str) -> tuple[Any, bool]:
if isinstance(new, dict):
return self._merge_dict(old, new, path)
if isinstance(new, list):
return self._merge_list(old, new, key, path)
if key in _LABEL_KEYS or not isinstance(new, str):
return self._merge_scalar(old, new)
if old is not None and not isinstance(old, str):
raise _MergeConflictError
return (old or "") + new, new != ""
@staticmethod
def _merge_scalar(old: Any, new: Any) -> tuple[Any, bool]:
"""标签键与数字/布尔: 同值幂等,不同值冲突(不学 SDK 相加,计数相加会造假)。"""
if old is None:
return new, False
if type(old) is type(new) and old == new:
return old, False
raise _MergeConflictError
def _merge_dict(self, old: Any, new: dict[str, Any], path: str) -> tuple[Any, bool]:
if old is not None and not isinstance(old, dict):
raise _MergeConflictError
target = old if old is not None else {}
grew = False
for sub_key, sub_value in new.items():
grew |= self.merge_key(target, sub_key, sub_value, f"{path}.{sub_key}")
return target, grew
def _merge_list(self, old: Any, new: list[Any], key: str, path: str) -> tuple[Any, bool]:
if old is not None and not isinstance(old, list):
raise _MergeConflictError
target = old if old is not None else []
items = [item for item in new if item is not None]
if not items:
return target, False
indexed = _is_indexed(items)
if self._array_modes.setdefault(path, indexed) != indexed:
raise _MergeConflictError # 形态改变: 首次出现决定合并策略
if indexed:
return target, self._merge_indexed(target, items, f"{path}[]")
return target, self._merge_appended(target, items, key, f"{path}[]")
def _merge_appended(self, target: list[Any], items: list[Any], key: str, path: str) -> bool:
grew = False
for item in items:
merged, item_grew = self._merge(None, item, key, path)
target.append(merged)
grew |= item_grew
return grew
def _merge_indexed(self, target: list[Any], items: list[Any], path: str) -> bool:
grew = False
for item in items:
slot = next((e for e in target if e.get("index") == item["index"]), None)
if slot is None:
slot = {}
target.append(slot)
grew |= self._merge_dict(slot, item, path)[1]
return grew
@dataclass(frozen=True)
class ChatDelta:
"""一帧提取结果;progress 表示真实生成进展。
`reasoning` 是 `reasoning` 字段(vLLM/OpenRouter 新名)的片段,投影 thinking 时
作为 `reasoning_content` 缺席时的别名(设计 §4.4)。
"""
content: str
reasoning_content: str
progress: bool
reasoning: str = ""
class ChatCompletionAccumulator:
"""每次 transport 尝试独享的聚合器,文本模式不严格校验工具产物。"""
def __init__(self, *, response_mode: ResponseMode) -> None:
"""初始化尝试私有状态。"""
self.mode = response_mode
self.message: dict[str, Any] = {}
self.metadata: dict[str, Any] = {}
self.tools: dict[int, dict[str, Any]] = {}
self._merger = _ExtensionMerger()
self._finish_seen = False
self._usage_after_finish = False
# —— 只读视图(transport 判定消费) ——
@property
def finish_reason(self) -> str | None:
"""choice 0 首个非 null finish_reason;未见为 None。"""
return self.metadata.get("finish_reason")
@property
def finish_seen(self) -> bool:
"""是否已收到 finish_reason。"""
return self._finish_seen
@property
def usage_after_finish(self) -> bool:
"""finish 同帧或之后是否收到非 null usage(设计 §4.2)。"""
return self._usage_after_finish
@property
def conflicts(self) -> frozenset[str]:
"""被删除的冲突键路径(数组下标已归一为 `[]`)。"""
return frozenset(self._merger.conflicts)
def raw_message(self) -> dict[str, Any]:
"""未校验的合并消息深拷贝(含排序工具),供构造快照前的结局判定。"""
return _assemble(self.message, self.tools)
# —— 聚合 ——
def add_chunk(self, chunk: dict[str, Any]) -> ChatDelta:
"""聚合一个已解码帧,一帧至多一个活性事件。"""
chunk = _object(chunk, "chunk")
if self.mode == "openai":
_identity(self.metadata, "id", chunk.get("id"))
choice = self._take_choice(chunk.get("choices"))
delta = self._take_delta(choice) if choice is not None else ChatDelta("", "", False)
self._take_usage(chunk.get("usage"))
return delta
def _take_usage(self, usage: Any) -> None:
"""usage 在 finish 处理之后登记,使同帧 finish+usage 算作"之后"。"""
if usage is None:
return
self.metadata["usage"] = _object(usage, "usage")
if self._finish_seen:
self._usage_after_finish = True
def _take_choice(self, choices: Any) -> dict[str, Any] | None:
"""选出本帧 choice 并登记 finish;usage-only 帧返回 None。"""
if choices is None:
return None
if not isinstance(choices, list):
raise _invalid("choices 必须为数组")
if not choices:
return None
if self.mode == "openai":
if len(choices) != 1:
raise _invalid("仅支持单 choice")
choice = _object(choices[0], "choice")
if type(choice.get("index")) is not int or choice["index"] != 0:
raise _invalid("choice index 必须为 0")
_identity(self.metadata, "finish_reason", choice.get("finish_reason"))
else:
choice = self._text_choice(choices)
if choice is None:
return None
self._finish_seen = self._finish_seen or self.finish_reason is not None
return choice
def _text_choice(self, choices: list[Any]) -> dict[str, Any] | None:
"""文本模式取 index 缺省或为 0 的首个 choice,不混入其他 choice(n>1)。"""
for raw in choices:
choice = _object(raw, "choice")
index = choice.get("index")
# null 视缺省(=0);bool 不得冒充 0(`False == 0` 为真)
if index is None or (type(index) is int and index == 0):
finish = choice.get("finish_reason")
if isinstance(finish, str) and "finish_reason" not in self.metadata:
self.metadata["finish_reason"] = finish
return choice
return None
def _take_delta(self, choice: dict[str, Any]) -> ChatDelta:
"""拆分已知与未知字段;已知字段语义逐字沿用 1.3.9。"""
raw_delta = choice.get("delta")
delta = _object(raw_delta if raw_delta is not None else {}, "delta")
if self.mode == "openai" and "role" in delta:
self._take_role(delta["role"])
content = self._take_text(delta, "content")
reasoning_content = self._take_text(delta, "reasoning_content")
alias = delta.get("reasoning") if isinstance(delta.get("reasoning"), str) else ""
progress = bool(content or reasoning_content)
if "tool_calls" in delta:
progress |= self._take_tools(delta["tool_calls"])
progress |= self._take_unknown(delta)
return ChatDelta(content, reasoning_content, progress, alias)
def _take_role(self, role: Any) -> None:
if role is None:
self.message.setdefault("role", None)
return
if role != "assistant":
raise _invalid("role 必须为 assistant")
self.message["role"] = role
def _take_text(self, delta: dict[str, Any], key: str) -> str:
if key not in delta:
return ""
if self.mode == "openai" or delta[key] is not None:
return _append(self.message, key, delta[key])
return ""
def _take_unknown(self, delta: dict[str, Any]) -> bool:
"""未知 delta 键: 保真合并进 message;文本只算活性。"""
grew = False
for key, value in delta.items():
if key in _DELTA_KNOWN:
continue
if self.mode == "openai":
grew |= self._merger.merge_key(self.message, key, value, f"delta.{key}")
else:
grew |= _fragment_progress(key, value)
return grew
def _take_tools(self, tools: Any) -> bool:
if self.mode != "openai":
return isinstance(tools, list) and self._each_tool(tools, self._text_tool_progress)
if tools is None:
self.message.setdefault("tool_calls", None)
return False
if not isinstance(tools, list):
raise _invalid("tool_calls 必须为数组")
present = [tool for tool in tools if tool is not None]
if self.message.get("tool_calls") is None and (present or not tools):
# 显式 [] 原样保留;[null] 不造空数组(回传空 tool_calls 会被上游拒)
self.message["tool_calls"] = []
return self._each_tool(present, lambda raw: self._take_tool(_object(raw, "tool")))
@staticmethod
def _each_tool(tools: list[Any], take: Any) -> bool:
"""逐个非 null 工具增量聚合,返回本帧是否有进展。"""
progress = False
for raw in tools:
if raw is not None:
progress |= take(raw)
return progress
def _text_tool_progress(self, raw: Any) -> bool:
"""文本只消费可识别的工具进展,不承诺工具完整性。"""
if not isinstance(raw, dict) or type(raw.get("index")) is not int:
return False
tool = self.tools.setdefault(raw["index"], {})
progress = False
for key in ("id", "type"):
if isinstance(raw.get(key), str) and raw[key] and key not in tool:
tool[key] = raw[key]
progress = True
fn = raw.get("function")
if isinstance(fn, dict):
progress |= any(
isinstance(fn.get(k), str) and bool(fn[k]) for k in ("name", "arguments")
)
return progress
def _take_tool(self, raw: dict[str, Any]) -> bool:
index = raw.get("index")
if type(index) is not int or index < 0:
raise _invalid("tool index 必须为非负整数")
tool = self.tools.setdefault(index, {})
progress = _identity(tool, "id", raw.get("id"))
progress |= _identity(tool, "type", raw.get("type"))
if raw.get("function") is not None:
progress |= self._take_function(tool, _object(raw["function"], "function"))
for key, value in raw.items():
if key not in _TOOL_KNOWN:
progress |= self._merger.merge_key(tool, key, value, f"tool_calls[].{key}")
return progress
def _take_function(self, tool: dict[str, Any], fn: dict[str, Any]) -> bool:
target = tool.setdefault("function", {})
progress = False
for key, value in fn.items():
if key in _FUNCTION_KNOWN:
progress |= bool(_append(target, key, value))
else:
path = f"tool_calls[].function.{key}"
progress |= self._merger.merge_key(target, key, value, path)
return progress
def finish(self) -> OpenAIChatCompletion:
"""线路完整性由调用者保证;此处只校验最终消息。"""
return _completion(self.raw_message(), self.metadata)
def _strip_null_tools(message: dict[str, Any]) -> dict[str, Any]:
"""null 工具元素剔除: 与流式跳过同口径,原样回传 [null] 会被上游拒。"""
tools = message.get("tool_calls")
if isinstance(tools, list) and None in tools:
kept = [tool for tool in tools if tool is not None]
# 全为 null 视同 tool_calls:null,不造会被上游拒收的空数组
return {**message, "tool_calls": kept or None}
return message
def _single_choice(body: dict[str, Any]) -> dict[str, Any]:
"""恰好一个 choice,index 缺省或为 0;拒绝静默截取多 choice。"""
choices = body.get("choices")
if not isinstance(choices, list) or len(choices) != 1:
raise _invalid("仅支持单 choice")
choice = _object(choices[0], "choice")
if "index" in choice and (type(choice["index"]) is not int or choice["index"] != 0):
raise _invalid("choice index 必须为 0")
return choice
def check_completion_body(body: Any) -> tuple[dict[str, Any], str | None]:
"""非流式保真形态校验(不构造快照): 单 choice、index 为 0、finish 为字符串或 null。
与快照构造拆开: 结局判定要先于构造(截断的半截工具须判 ResultInvalid,
构造时抛 Transient 就到不了那一行,设计 §4.3 行 2)。
"""
choice = _single_choice(_object(body, "body"))
finish = choice.get("finish_reason")
if finish is not None and not isinstance(finish, str):
raise _invalid("finish_reason 必须为字符串或 null")
message = _object(choice.get("message"), "message")
if message.get("role") != "assistant":
# 形态失败先于结局判定,否则缺 role 的截断工具会被归成 ResultInvalid
raise _invalid("role 必须为 assistant")
return _strip_null_tools(message), finish
def build_completion(
message: dict[str, Any], body: dict[str, Any], finish: str | None
) -> OpenAIChatCompletion:
"""非流式快照构造;保留消息扩展字段。"""
return _completion(message, {**body, "finish_reason": finish})
def parse_chat_completion(body: dict[str, Any]) -> OpenAIChatCompletion:
"""非流式完整消息保留扩展字段,拒绝静默截取多 choice。"""
message, finish = check_completion_body(body)
return build_completion(message, body, finish)
+262 -44
View File
@@ -10,11 +10,10 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import math
import zipfile import zipfile
from contextlib import contextmanager from contextlib import contextmanager
from io import BytesIO from io import BytesIO
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any, Literal
import httpx import httpx
@@ -29,7 +28,13 @@ from polygateway.transports._http_errors import (
response_body, response_body,
summarize_body, summarize_body,
) )
from polygateway.transports._monkey_document import (
finite_number,
parse_document_package,
parse_page_size,
)
from polygateway.types import ( from polygateway.types import (
OcrDocumentTransportResult,
OcrLayoutElement, OcrLayoutElement,
OcrLayoutTransportResult, OcrLayoutTransportResult,
OcrTextTransportResult, OcrTextTransportResult,
@@ -42,6 +47,88 @@ if TYPE_CHECKING:
_HEALTH_TIMEOUT_S = 5.0 # 探测常量(同 jitter 系数先例,不进配置) _HEALTH_TIMEOUT_S = 5.0 # 探测常量(同 jitter 系数先例,不进配置)
_MediaKind = Literal["pdf", "image", "unknown"]
# 已知图片魔数。起始命中即判图片,且**优先于** PDF 判定: 含 XMP 元数据的图片
# 可能在前 1KB 内带 `%PDF-` 字样,不能因此被当成 PDF。
_IMAGE_MAGICS: tuple[bytes, ...] = (
b"\xff\xd8\xff", # JPEG
b"\x89PNG\r\n\x1a\n", # PNG
b"II*\x00", # TIFF 小端
b"MM\x00*", # TIFF 大端
b"GIF87a",
b"GIF89a",
)
_PDF_HEADER = b"%PDF-"
# BMP 的 "BM" 只有两字节,任何以 BM 开头的文本都会撞上。再要求偏移 14 处的 DIB 头大小字段合法
_BMP_DIB_HEADER_SIZES = frozenset({12, 40, 52, 56, 64, 108, 124})
# 文档路径把这些「确定性坏输入」的 500 改归 RequestRejected(#34 设计 §6)。小写子串匹配,
# 取 2026-10-21 实测(截断 PDF / 伪 PDF 头 / 乱码 / 空文件 / 扩展名与内容不符)。
# **不含** `unsupported file type`: 嗅探后它只可能由服务契约变化或我方标签 bug 引起,
# 应响亮失败并计熔断。标记是 PyMuPDF 的界面文案,随服务版本漂移,故可经构造参数覆盖。
_DEFAULT_REJECTED_MARKERS: tuple[str, ...] = (
"failed to open stream",
"cannot open empty stream",
"cannot save with zero pages",
"is no pdf",
)
# 服务把一切异常(含它自己抛的 400、`Model not initialized` 这类真故障)都包成同一前缀,
# 所以前缀只是必要条件,必须同时命中具体标记
_PARSING_FAILED_PREFIX = "parsing failed:"
_ERROR_BODY_READ_CAP = 64 * 1024
"""文档路径读取错误响应体的字节上限。摘要只取几 KiB(`summarize_body`),服务或中间人回一个
巨大的错误体不该被整个读进内存(独立审查实测 600 MiB 的 500 让 RSS 涨到 1.8 GiB)。"""
_MAX_PACKAGE_BYTES = 256 * 1024 * 1024
"""整包(含 `images/*.jpg`)体积上限,仅文档路径。4 页含 4 图约 598KB,数百页含图的文档
也远低于此;超限即拒,防止畸形/恶意响应把内存吃满。httpx 非流式 `get` 会先整包 `aread()`,
这正是文档路径改用流式下载并逐块累计的原因。"""
# Adobe 阅读器容忍头部前有垃圾字节(惯例而非 ISO 32000 正文);服务端实测可解析
# 前缀 200 字节垃圾的 PDF(#34 设计 E3)
_PDF_HEADER_WINDOW = 1024
def _is_webp(data: bytes) -> bool:
"""RIFF 容器且类型码为 WEBP。"""
return data[:4] == b"RIFF" and data[8:12] == b"WEBP"
def _is_bmp(data: bytes) -> bool:
"""`BM` 开头且 DIB 头大小字段合法(单靠两字节魔数会误判以 BM 开头的文本)。"""
return (
data.startswith(b"BM")
and len(data) >= 18
and int.from_bytes(data[14:18], "little") in _BMP_DIB_HEADER_SIZES
)
def _sniff_media(data: bytes) -> _MediaKind:
"""按内容判定上传类型: 图片魔数 → 前 1KB 内 `%PDF-` → 未知。
服务端按**文件名扩展名**分派(`.pdf` 走 PDF 数据集,其余走图片数据集),而标签
若与内容不符会得到 HTTP 500(`is no PDF`)或——在 `/ocr/text` 上——`success:true`
的幻觉文本(#34 设计 E2/E4)。所以标签只能由库按内容定,不开放给调用方声明。
"""
if data.startswith(_IMAGE_MAGICS) or _is_webp(data) or _is_bmp(data):
return "image"
if _PDF_HEADER in data[:_PDF_HEADER_WINDOW]:
return "pdf"
return "unknown"
def _upload_part(data: bytes) -> tuple[str, bytes, str]:
"""multipart 的 (filename, content, content_type)。
只有 PDF 改标 `doc.pdf`;**其余(含未知)一律沿用历史的 `image.jpg`**——服务对
PNG/TIFF/BMP 字节标 `image.jpg` 照样按内容解码(2026-10-21 实测),任何"更精确"
的改标都只会把现在能成功的路径变成失败(webp/gif 没有对应的服务端扩展名)。
"""
if _sniff_media(data) == "pdf":
return ("doc.pdf", data, "application/pdf")
return ("image.jpg", data, "image/jpeg")
def _default_client_factory(source: SourceConfig) -> httpx.AsyncClient: def _default_client_factory(source: SourceConfig) -> httpx.AsyncClient:
return httpx.AsyncClient( return httpx.AsyncClient(
@@ -52,11 +139,14 @@ def _default_client_factory(source: SourceConfig) -> httpx.AsyncClient:
@contextmanager @contextmanager
def _translate_http_errors(source_name: str, operation: str) -> Iterator[None]: def _translate_http_errors(
source_name: str, operation: str, *, rejected_markers: tuple[str, ...] = ()
) -> Iterator[None]:
"""httpx 异常 → 错误四分类(CHS `_translate_http_errors` 语义主体保真)。 """httpx 异常 → 错误四分类(CHS `_translate_http_errors` 语义主体保真)。
429 细分(insufficient_quota/Retry-After)有意放弃——MonkeyOCR 无鉴权 429 细分(insufficient_quota/Retry-After)有意放弃——MonkeyOCR 无鉴权
无计费(设计 §10.2);`from exc` 保留 __cause__ 供 RetryMW 归类 timeout。 无计费(设计 §10.2);`from exc` 保留 __cause__ 供 RetryMW 归类 timeout。
`rejected_markers` 非空时启用确定性坏输入窄化(仅文档路径传入,见 `_classify_status`)。
""" """
try: try:
yield yield
@@ -67,7 +157,7 @@ def _translate_http_errors(source_name: str, operation: str) -> Iterator[None]:
operation=operation, operation=operation,
) from exc ) from exc
except httpx.HTTPStatusError as exc: except httpx.HTTPStatusError as exc:
raise _classify_status(exc, source_name, operation) from exc raise _classify_status(exc, source_name, operation, rejected_markers) from exc
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
raise TransientError( raise TransientError(
f"{source_name} OCR {operation} 网络错误: {exc}", f"{source_name} OCR {operation} 网络错误: {exc}",
@@ -76,16 +166,31 @@ def _translate_http_errors(source_name: str, operation: str) -> Iterator[None]:
) from exc ) from exc
def _is_deterministic_failure(body: str, markers: tuple[str, ...]) -> bool:
"""响应体同时含 `Parsing failed:` 前缀与任一已知确定性标记。"""
text = body.lower()
return _PARSING_FAILED_PREFIX in text and any(marker in text for marker in markers)
def _classify_status( def _classify_status(
exc: httpx.HTTPStatusError, source_name: str, operation: str exc: httpx.HTTPStatusError,
source_name: str,
operation: str,
rejected_markers: tuple[str, ...] = (),
) -> TransientError | SourceDeadError | RequestRejectedError: ) -> TransientError | SourceDeadError | RequestRejectedError:
"""HTTP 状态码 → 错误四分类,**全部分支**携带响应体摘要(issue #10)。 """HTTP 状态码 → 错误四分类,**全部分支**携带响应体摘要(issue #10)。
分类映射本身零变更;摘要口径与 chat 侧共用同一实现,不得在此另起一份—— 分类映射本身零变更;摘要口径与 chat 侧共用同一实现,不得在此另起一份——
"只有一个分支用了响应体"正是 issue #10 的成因。 "只有一个分支用了响应体"正是 issue #10 的成因。
唯一的可选窄化: `rejected_markers` 非空且为 500 且命中确定性坏输入标记时,归
`RequestRejectedError`(**保留 `status_code=500`**: 服务有响应,熔断按「响应即健康」
记成功且不计窗口)。默认空元组 = 既有行为字节级不变——既有调用方对坏图的处置属公共行为,
推广须另行设计(issue #35)。
""" """
status = exc.response.status_code status = exc.response.status_code
summary = summarize_body(response_body(exc.response)) body = response_body(exc.response)
summary = summarize_body(body)
ctx: dict[str, Any] = { ctx: dict[str, Any] = {
"source_name": source_name, "source_name": source_name,
"status_code": status, "status_code": status,
@@ -93,6 +198,8 @@ def _classify_status(
"body_text": summary, "body_text": summary,
} }
message = compose_message(f"{source_name} OCR {operation} HTTP {status}", summary) message = compose_message(f"{source_name} OCR {operation} HTTP {status}", summary)
if status == 500 and rejected_markers and _is_deterministic_failure(body, rejected_markers):
return RequestRejectedError(message, **ctx)
if status >= 500 or status == 429: if status >= 500 or status == 429:
return TransientError(message, **ctx) return TransientError(message, **ctx)
if status in (401, 403): if status in (401, 403):
@@ -100,30 +207,10 @@ def _classify_status(
return RequestRejectedError(message, **ctx) return RequestRejectedError(message, **ctx)
def _finite_number(value: object) -> float:
"""JSON 数值 → 有限浮点;布尔与非有限数拒绝(CHS invokers.py:427 逐字)。"""
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValueError("字段不是数值")
number = float(value)
if not math.isfinite(number):
raise ValueError("字段不是有限数值")
return number
def _parse_page_size(page: dict) -> tuple[float, float]:
page_size = page.get("page_size")
if not isinstance(page_size, list) or len(page_size) != 2:
raise ValueError("page_size 无效")
width, height = (_finite_number(item) for item in page_size)
if width <= 0 or height <= 0:
raise ValueError("page_size 必须为正数")
return (width, height)
def _parse_bbox(bbox: object) -> tuple[float, float, float, float]: def _parse_bbox(bbox: object) -> tuple[float, float, float, float]:
if not isinstance(bbox, list) or len(bbox) != 4: if not isinstance(bbox, list) or len(bbox) != 4:
raise ValueError("bbox 无效") raise ValueError("bbox 无效")
x1, y1, x2, y2 = (_finite_number(item) for item in bbox) x1, y1, x2, y2 = (finite_number(item) for item in bbox)
if x2 <= x1 or y2 <= y1: if x2 <= x1 or y2 <= y1:
raise ValueError("bbox 边界顺序无效") raise ValueError("bbox 边界顺序无效")
# 库返回 float 原生 bbox;整数化退化校验专为 CHS shim 的 int() 裁剪路径 # 库返回 float 原生 bbox;整数化退化校验专为 CHS shim 的 int() 裁剪路径
@@ -152,9 +239,9 @@ def _parse_pages(payload: object) -> tuple[list[OcrLayoutElement], list[tuple[fl
for fallback_index, page in enumerate(payload["pdf_info"]): for fallback_index, page in enumerate(payload["pdf_info"]):
if not isinstance(page, dict): if not isinstance(page, dict):
raise ValueError("页面结构无效") raise ValueError("页面结构无效")
page_sizes.append(_parse_page_size(page)) page_sizes.append(parse_page_size(page))
raw_index = page.get("page_idx", fallback_index) raw_index = page.get("page_idx", fallback_index)
# bool 是 int 子类、负页号无意义——与 _finite_number 拒 bool 的口径一致 # bool 是 int 子类、负页号无意义——与 finite_number 拒 bool 的口径一致
valid_idx = ( valid_idx = (
isinstance(raw_index, int) and not isinstance(raw_index, bool) and raw_index >= 0 isinstance(raw_index, int) and not isinstance(raw_index, bool) and raw_index >= 0
) )
@@ -186,6 +273,64 @@ def _parse_middle_json(
raise ResultInvalidError(f"OCR 结果包内容无效: {exc}") from exc raise ResultInvalidError(f"OCR 结果包内容无效: {exc}") from exc
def _check_download_url(download_url: str, source: SourceConfig) -> None:
"""`download_url` 来自服务端响应,属外部输入:畸形值先于第二段请求拒绝。
控制字符、坏方括号会让 httpx 抛 `InvalidURL`(不属 `HTTPError` 体系),越界端口会在连接阶段
抛 ExceptionGroup;二者都会从 `_translate_http_errors` 下漏出四分类,`OcrClient._call` 只接
`PolyGatewayError`,于是不写终态遥测行、持有的半开探针也不归还。服务返回畸形 URL 是协议故障,
与"响应缺少 download_url"同归瞬时错误。**不限制主机**(基线接受绝对地址,测试钉住)。
"""
try:
url = httpx.URL(download_url)
except httpx.InvalidURL as exc:
raise TransientError(
f"{source.name} /parse 响应的 download_url 非法: {exc}",
source_name=source.name,
operation="parse",
) from exc
if url.port is not None and not 0 < url.port <= 65535:
raise TransientError(
f"{source.name} /parse 响应的 download_url 端口越界: {url.port}",
source_name=source.name,
operation="parse",
)
def _package_too_large(source: SourceConfig, size: int) -> ResultInvalidError:
return ResultInvalidError(
f"{source.name} OCR 结果包体积超过上限 {_MAX_PACKAGE_BYTES}(已读 {size})",
source_name=source.name,
operation="download_result",
)
async def _bounded_error_response(resp: httpx.Response) -> httpx.Response:
"""读错误响应体的前 `_ERROR_BODY_READ_CAP` 字节,复制成已缓冲的响应。"""
data = bytearray()
async for chunk in resp.aiter_bytes():
data += chunk
if len(data) >= _ERROR_BODY_READ_CAP:
break
headers = {k: v for k, v in resp.headers.items() if k.lower() == "content-type"}
return httpx.Response(
resp.status_code,
headers=headers,
content=bytes(data[:_ERROR_BODY_READ_CAP]),
request=resp.request,
)
def _check_declared_length(resp: httpx.Response, source: SourceConfig) -> None:
"""声明长度超限就不必读了;缺失/非法的头部不作判据(靠累计字节兜底)。"""
try:
declared = int(resp.headers.get("content-length", ""))
except ValueError:
return
if declared > _MAX_PACKAGE_BYTES:
raise _package_too_large(source, declared)
class MonkeyOcrTransport: class MonkeyOcrTransport:
"""MonkeyOCR transport: 每源一个预配 httpx client,懒创建,aclose 统一释放。""" """MonkeyOCR transport: 每源一个预配 httpx client,懒创建,aclose 统一释放。"""
@@ -193,8 +338,11 @@ class MonkeyOcrTransport:
self, self,
*, *,
client_factory: Callable[[SourceConfig], httpx.AsyncClient] | None = None, client_factory: Callable[[SourceConfig], httpx.AsyncClient] | None = None,
deterministic_failure_markers: tuple[str, ...] = _DEFAULT_REJECTED_MARKERS,
) -> None: ) -> None:
self._client_factory = client_factory or _default_client_factory self._client_factory = client_factory or _default_client_factory
# 仅文档路径使用(见 `_classify_status`);recognize_text / parse_layout 不传
self._rejected_markers = deterministic_failure_markers
self._clients: dict[str, httpx.AsyncClient] = {} self._clients: dict[str, httpx.AsyncClient] = {}
def _client_for(self, source: SourceConfig) -> httpx.AsyncClient: def _client_for(self, source: SourceConfig) -> httpx.AsyncClient:
@@ -205,9 +353,10 @@ class MonkeyOcrTransport:
return client return client
@staticmethod @staticmethod
def _files(image: bytes) -> dict[str, tuple[str, bytes, str]]: def _files(data: bytes) -> dict[str, tuple[str, bytes, str]]:
# 固定文件名/类型(CHS invokers.py:496 原样;服务按内容处理,35 样本实测) # 标签按内容判定(#34): 非 PDF 沿用 CHS invokers.py:496 的 image.jpg,
return {"file": ("image.jpg", image, "image/jpeg")} # PDF 改标 doc.pdf——服务按扩展名分派,固定名对 PDF 是错的
return {"file": _upload_part(data)}
def _response_json(self, resp: httpx.Response, source: SourceConfig, operation: str) -> dict: def _response_json(self, resp: httpx.Response, source: SourceConfig, operation: str) -> dict:
"""响应体 JSON 化;坏 JSON/非 dict → Transient(设计 §4: 服务侧瞬时异常)。""" """响应体 JSON 化;坏 JSON/非 dict → Transient(设计 §4: 服务侧瞬时异常)。"""
@@ -249,32 +398,48 @@ class MonkeyOcrTransport:
text=content, raw={k: v for k, v in data.items() if k != "content"} text=content, raw={k: v for k, v in data.items() if k != "content"}
) )
async def parse_layout( async def _post_parse(
self, *, image: bytes, source: SourceConfig, call_id: str self,
) -> OcrLayoutTransportResult: client: httpx.AsyncClient,
"""两段协议: POST /parse → GET download_url → ZIP → 全元素提取。""" data: bytes,
del call_id source: SourceConfig,
client = self._client_for(source) rejected_markers: tuple[str, ...],
with _translate_http_errors(source.name, "parse"): ) -> str:
resp = await client.post("/parse", files=self._files(image)) """两段协议的第一段: POST /parse → 校验 `success` → 返回 download_url。
layout 与 document 两条路径共用,保证"success 为假 → 附 status_code=200"等
设计 §10.2 的有意修复只有一份。
"""
with _translate_http_errors(source.name, "parse", rejected_markers=rejected_markers):
resp = await client.post("/parse", files=self._files(data))
resp.raise_for_status() resp.raise_for_status()
data = self._response_json(resp, source, "parse") body = self._response_json(resp, source, "parse")
if data.get("success") is not True: if body.get("success") is not True:
# 设计 §10.2 有意修复: 200 响应确证服务活着,附 status_code # 设计 §10.2 有意修复: 200 响应确证服务活着,附 status_code
# 让治理侧按"响应即健康"记熔断成功(CHS 原样会误走本地拒绝分支) # 让治理侧按"响应即健康"记熔断成功(CHS 原样会误走本地拒绝分支)
raise RequestRejectedError( raise RequestRejectedError(
f"{source.name} OCR 解析失败: {data.get('message')}", f"{source.name} OCR 解析失败: {body.get('message')}",
source_name=source.name, source_name=source.name,
status_code=resp.status_code, status_code=resp.status_code,
operation="parse", operation="parse",
) )
download_url = data.get("download_url") download_url = body.get("download_url")
if not isinstance(download_url, str) or not download_url.strip(): if not isinstance(download_url, str) or not download_url.strip():
raise TransientError( raise TransientError(
f"{source.name} /parse 响应缺少 download_url", f"{source.name} /parse 响应缺少 download_url",
source_name=source.name, source_name=source.name,
operation="parse", operation="parse",
) )
_check_download_url(download_url, source)
return download_url
async def parse_layout(
self, *, image: bytes, source: SourceConfig, call_id: str
) -> OcrLayoutTransportResult:
"""两段协议: POST /parse → GET download_url → ZIP → 全元素提取。"""
del call_id
client = self._client_for(source)
download_url = await self._post_parse(client, image, source, ())
with _translate_http_errors(source.name, "download_result"): with _translate_http_errors(source.name, "download_result"):
# 实测 download_url 为相对路径(/static/*.zip),相对 base_url 解析; # 实测 download_url 为相对路径(/static/*.zip),相对 base_url 解析;
# httpx 对绝对 URL 会忽略 base_url,两种形态天然兼容 # httpx 对绝对 URL 会忽略 base_url,两种形态天然兼容
@@ -287,6 +452,59 @@ class MonkeyOcrTransport:
raw={"success": True, "download_url": download_url}, raw={"success": True, "download_url": download_url},
) )
async def parse_document(
self, *, document: bytes, source: SourceConfig, call_id: str
) -> OcrDocumentTransportResult:
"""整份文档: 本地预检 → POST /parse → 流式 GET → 线程内解析(#34 设计 §5)。"""
del call_id
if _sniff_media(document) == "unknown":
# 服务从未被打扰: HTML 付费墙页面、乱码、空串在批处理语料里最常见。不带
# status_code(服务没响应),治理侧只归还探针、不计熔断成败
raise RequestRejectedError(
f"{source.name} OCR 无法识别文档格式(既非 PDF 也非已知图片),拒绝发送",
source_name=source.name,
operation="parse",
)
client = self._client_for(source)
download_url = await self._post_parse(client, document, source, self._rejected_markers)
package = await self._download_package(client, download_url, source)
# 纯函数、可能处理大对象: 放线程里,取消与 call_deadline_s 才能及时生效
elements, page_sizes, markdown = await asyncio.to_thread(parse_document_package, package)
return OcrDocumentTransportResult(
elements=elements,
page_sizes=page_sizes,
markdown=markdown,
raw={"success": True, "download_url": download_url},
)
async def _download_package(
self, client: httpx.AsyncClient, download_url: str, source: SourceConfig
) -> bytes:
"""流式下载结果包并限制体积。
**先处理错误状态,再检查体积**: 结果包的资源检查不得改变 HTTP 错误分类
(一个声明超限的 401 仍是 SourceDead,响应体摘要也要保留——issue #10)。
"""
chunks: list[bytes] = []
total = 0
with _translate_http_errors(source.name, "download_result"):
async with client.stream("GET", download_url) as resp:
if not resp.is_success:
# 条件是「非 2xx」而不是 `is_error`:1xx/3xx 同样需要保留摘要。错误体只读
# 有界的前缀并复制成已缓冲的响应(摘要口径只认已缓冲的响应体)
raise httpx.HTTPStatusError(
f"HTTP {resp.status_code}",
request=resp.request,
response=await _bounded_error_response(resp),
)
_check_declared_length(resp, source)
async for chunk in resp.aiter_bytes():
total += len(chunk)
if total > _MAX_PACKAGE_BYTES:
raise _package_too_large(source, total)
chunks.append(chunk)
return b"".join(chunks)
async def check_health(self, *, source: SourceConfig) -> bool: async def check_health(self, *, source: SourceConfig) -> bool:
"""GET /health 探测: 2xx → True,失败 → False,取消穿透(R10)。""" """GET /health 探测: 2xx → True,失败 → False,取消穿透(R10)。"""
client = self._client_for(source) client = self._client_for(source)
+357 -85
View File
@@ -9,9 +9,10 @@ SSE 纯函数移植 VT `adapters/llm.py:51-124`;错误翻译移植 CHS
from __future__ import annotations from __future__ import annotations
import json import json
import math
import re import re
import time import time
from dataclasses import replace from dataclasses import dataclass, field, replace
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
import httpx import httpx
@@ -37,17 +38,35 @@ from polygateway.thinking import (
validate_thinking_raw, validate_thinking_raw,
) )
from polygateway.transports._http_errors import compose_message, summarize_body from polygateway.transports._http_errors import compose_message, summarize_body
from polygateway.transports._openai_completion import (
ChatCompletionAccumulator,
ChatDelta,
build_completion,
check_completion_body,
)
from polygateway.types import ( from polygateway.types import (
Effort, Effort,
EmbeddingTransportResult, EmbeddingTransportResult,
ResponseMode,
SourceConfig, SourceConfig,
StreamEnd,
TransportResult, TransportResult,
_classify_finish,
_judge_outcome,
_stream_is_complete,
_summarize_message,
_summarize_text,
) )
if TYPE_CHECKING: if TYPE_CHECKING:
import asyncio
from collections.abc import AsyncIterator, Callable, Mapping from collections.abc import AsyncIterator, Callable, Mapping
_THINK_PATTERN = re.compile(r"<think>(.*?)</think>", re.DOTALL) _THINK_PATTERN = re.compile(r"<think>(.*?)</think>", re.DOTALL)
# 方言告警节流上限与 finish 值截断(设计 §5.4): 键由上游决定,必须有界
_WARN_CAP = 1024
_FINISH_WARN_CHARS = 64
_PATH_WARN_CHARS = 128
# —— SSE 纯函数(VT llm.py 同款)—— # —— SSE 纯函数(VT llm.py 同款)——
@@ -60,33 +79,48 @@ def _sse_data_payload(raw: str) -> str | None:
return line[len("data:") :].strip() return line[len("data:") :].strip()
def _sse_delta(chunk: dict[str, Any], usage_sink: dict[str, Any]) -> tuple[bool, str] | None: def _stall_from_chunk_times(
"""从 chunk 提取增量: (True, content) 或 (False, reasoning);usage 帧旁路进 sink。""" times: list[float], threshold_ms: int
if chunk.get("usage"): ) -> tuple[float | None, float | None]:
usage_sink["usage"] = chunk["usage"] """增量 chunk 时间戳序列 → (stall_ms, stall_count);chunk <2 → (None, None)。
if "model" not in usage_sink:
# 首个**有效**值即固定: 末帧的异常值不得覆盖它;但首帧报空串也不能锁死 沉默 = 相邻间隔**严格大于**阈值(恰好 = 阈值不减,pi-tps 口径);
# sink——否则后续真实版本会丢(issue #3) 尾部(末 chunk → 流结束)不计——墙钟基底口径,尾部归活跃侧(设计 §4.2 N1)。
reported = _coerce_model_reported(chunk.get("model")) 无超阈段但 chunk ≥2 时返回 (0.0, 0.0) 而非 None——"观测过且零沉默"与
if reported is not None: "无从观测"是两回事,下游预算公式以 `stall_ms is None` 为分支条件(设计 §4.2 N2)。
usage_sink["model"] = reported """
choices = chunk.get("choices") or [] if len(times) < 2:
if not choices: return None, None
return None total = 0.0
delta = choices[0].get("delta") or {} count = 0
content = delta.get("content") # strict=False: 两序列恒差 1(times[1:] 少末尾),B905 要求显式表态——不等长是本设计的本意
if content: for prev, cur in zip(times, times[1:], strict=False):
return (True, content) gap_ms = (cur - prev) * 1000
reasoning = delta.get("reasoning_content") if gap_ms > threshold_ms:
if reasoning: total += gap_ms
return (False, reasoning) count += 1
return None return (total, float(count)) if count else (0.0, 0.0)
def _check_chat_error(chunk: Any) -> None:
"""所有模式拒绝顶层 error;诊断复用有界摘要。"""
if not isinstance(chunk, dict):
raise TransientError("chat 响应必须为 JSON 对象", operation="chat")
if "error" in chunk:
summary = summarize_body(json.dumps({"error": chunk["error"]}, ensure_ascii=False))
raise TransientError(
compose_message("上游 chat error 帧", summary), operation="chat", body_text=summary
)
if chunk.get("usage") is not None and not isinstance(chunk["usage"], dict):
raise TransientError("chat usage 必须为对象或 null", operation="chat")
async def _iter_sse_deltas( async def _iter_sse_deltas(
lines: AsyncIterator[str], usage_sink: dict[str, Any] lines: AsyncIterator[str],
) -> AsyncIterator[tuple[bool, str]]: usage_sink: dict[str, Any],
"""逐行解析 SSE 流;[DONE] 置 sink["done"];畸形 JSON 帧 → 瞬时错误(可重试)。""" accumulator: ChatCompletionAccumulator,
) -> AsyncIterator[ChatDelta]:
"""解析 SSE,一帧只产生一次进展事件。"""
async for raw in lines: async for raw in lines:
data = _sse_data_payload(raw) data = _sse_data_payload(raw)
if data is None: if data is None:
@@ -97,25 +131,165 @@ async def _iter_sse_deltas(
try: try:
chunk = json.loads(data) chunk = json.loads(data)
except json.JSONDecodeError as exc: except json.JSONDecodeError as exc:
raise TransientError( raise TransientError("SSE 帧畸形(malformed_json)", operation="chat") from exc
f"SSE 帧畸形(malformed_json): {data[:80]!r}", operation="chat" _check_chat_error(chunk)
) from exc if chunk.get("usage") is not None:
delta = _sse_delta(chunk, usage_sink) usage_sink["usage"] = chunk["usage"]
if delta is not None: if "model" not in usage_sink:
model = _coerce_model_reported(chunk.get("model"))
if model is not None:
usage_sink["model"] = model
delta = accumulator.add_chunk(chunk)
if delta.progress:
yield delta yield delta
@dataclass
class _StreamTiming:
"""一次流式尝试的计时与文本片段(每尝试新建,不跨重试/对冲共享)。"""
content_parts: list[str] = field(default_factory=list)
reasoning_parts: list[str] = field(default_factory=list)
alias_parts: list[str] = field(default_factory=list)
chunk_times: list[float] = field(default_factory=list)
ttft_ms: float | None = None
max_gap: float = 0.0
@property
def has_reasoning(self) -> bool:
"""出现过非空 reasoning_content: 别名 `reasoning` 让位(设计 §4.4)。"""
return bool(self.reasoning_parts)
async def _consume_deltas(
guarded: AsyncIterator[ChatDelta],
started: float,
first_token_event: asyncio.Event | None,
content_event: asyncio.Event | None,
) -> _StreamTiming:
"""消费进展事件: 计时、置位对冲事件、收集文本片段。"""
timing = _StreamTiming()
last = started
async for delta in guarded:
now = time.monotonic()
# 增量 chunk 时间戳(1.3.8 沉默计量): 循环体只会被真实增量触发——
# 空 chunk/usage 帧/SSE keepalive 已在聚合器被过滤
timing.chunk_times.append(now)
if content_event is not None:
# 每个增量 chunk 置位一次: 中段沉默对冲的活性复位信号
content_event.set()
if timing.ttft_ms is None:
timing.ttft_ms = (now - started) * 1000
# 首个增量即对冲语义上的"首 token"(思考增量同样是存活证据,
# 与看门狗活性口径一致);None = 调用方未启用对冲,零分支成本
if first_token_event is not None:
first_token_event.set()
else:
timing.max_gap = max(timing.max_gap, (now - last) * 1000)
last = now
for parts, text in (
(timing.content_parts, delta.content),
(timing.reasoning_parts, delta.reasoning_content),
(timing.alias_parts, delta.reasoning),
):
if text:
parts.append(text)
return timing
def _raise_unless_deliverable(
mode: ResponseMode,
message: dict[str, Any] | None,
content: str,
thinking: str,
finish_reason: str | None,
source: SourceConfig,
) -> None:
"""结局判定(设计 §4.3)的唯一翻译点: 判定值 → 领域错误。
原因码以固定格式进 message(遥测 error 列可检索);不回显推理/工具内容。
"""
if mode == "openai" and message is not None:
summary = _summarize_message(message, content)
else:
summary = _summarize_text(content, thinking)
verdict = _judge_outcome(mode=mode, summary=summary, finish=_classify_finish(finish_reason))
if verdict.deliver:
return
text = (
f"{source.name} {verdict.code}: finish_reason={finish_reason!r}, "
f"thinking_len={len(thinking)}"
)
if verdict.error_kind == "result_invalid":
raise ResultInvalidError(text, raw_text="", source_name=source.name, operation="chat")
raise TransientError(text, source_name=source.name, operation="chat")
def _text_choice(body: dict[str, Any], source: SourceConfig) -> tuple[Any, str | None]:
"""文本模式取首个 choice 的 message 与 finish(非字符串 finish 记 None)。"""
choices = body.get("choices") or []
if not choices:
raise TransientError(
f"{source.name} 响应缺 choices", source_name=source.name, operation="chat"
)
if not isinstance(choices, list) or not isinstance(choices[0], dict):
raise TransientError("chat choices 形态非法", operation="chat")
finish = choices[0].get("finish_reason")
return choices[0].get("message", {}), finish if isinstance(finish, str) else None
def _nonstream_message(
body: dict[str, Any], mode: ResponseMode, source: SourceConfig
) -> tuple[dict[str, Any], str | None]:
"""非流式取单 choice 的原始 message 与 finish;只校验形态,不构造快照。"""
if mode == "openai":
message, finish = check_completion_body(body)
else:
message, finish = _text_choice(body, source)
if not isinstance(message, dict) or any(
message.get(key) is not None and not isinstance(message[key], str)
for key in ("content", "reasoning_content")
):
raise TransientError("chat message 形态非法", operation="chat")
return message, finish
def _reasoning_of(message: dict[str, Any]) -> str:
"""非流式思考投影: 非空 reasoning_content 优先,否则 `reasoning` 别名。"""
primary = message.get("reasoning_content")
if isinstance(primary, str) and primary:
return primary
alias = message.get("reasoning")
return alias if isinstance(alias, str) else ""
# —— 错误翻译(CHS invokers.py 同款)—— # —— 错误翻译(CHS invokers.py 同款)——
def _parse_retry_after(raw: str | None) -> float | None: def _parse_retry_after(raw: str | None, *, source_name: str) -> float | None:
"""解析 Retry-After 头;仅支持秒数形态,HTTP-date 返回 None(CHS 同款)。""" """解析 Retry-After 头;仅支持秒数形态,HTTP-date 返回 None(CHS 同款)。
**非有限值必须当作"无提示"**(issue F1): `"inf"` / `"1e999"` 能被 `float()`
成功解析,又能通过 `seconds > 0`,于是一路变成 `retry_after_s=inf`——而
`backoff_delay` 的 `max(delay, retry_after)` 取大之后就是一次**永不醒来**的
退避 sleep(库刻意不拿 `backoff_max_s` 去夹它,见设计 §6.2)。
`source_name` 是**必填** keyword-only 参数: 本函数是私有的,不给默认值,
漏传即 `TypeError`,免得将来新增调用点静默丢掉源标识(告警定位不到是哪个源)。
`nan` 不新增分支,沿用既有 `seconds > 0` 恒假的值语义。
"""
if raw is None: if raw is None:
return None return None
try: try:
seconds = float(raw.strip()) seconds = float(raw.strip())
except ValueError: except ValueError:
return None return None
if math.isinf(seconds):
# 只写源名与判据词: 429 风暴下回显原始头会把日志淹掉,也无助于定位
logger.warning(
"{} 的 Retry-After 非有限值,按无提示处理(retry_after_not_finite)", source_name
)
return None
return seconds if seconds > 0 else None return seconds if seconds > 0 else None
@@ -137,7 +311,7 @@ def _translate_429(
) )
return TransientError( return TransientError(
compose_message(f"{source.name} 限速: 429", summary), compose_message(f"{source.name} 限速: 429", summary),
retry_after_s=_parse_retry_after(headers.get("retry-after")), retry_after_s=_parse_retry_after(headers.get("retry-after"), source_name=source.name),
**ctx, **ctx,
) )
@@ -346,6 +520,9 @@ class OpenAICompatTransport:
# 都会连带改掉另一侧的行为。(键空间恰好不相交,故当下**不会**互相压制; # 都会连带改掉另一侧的行为。(键空间恰好不相交,故当下**不会**互相压制;
# 分开维护的理由是语义,不是碰撞) # 分开维护的理由是语义,不是碰撞)
self._warned_mismatches: set[tuple[str, str, Effort | None]] = set() self._warned_mismatches: set[tuple[str, str, Effort | None]] = set()
# 方言告警(1.3.10)另立集合: 冲突删键/未知 finish 与推理对账语义无关
self._warned_fields: set[tuple[str, str]] = set()
self._warn_saturated = False
self._client_factory = client_factory or _default_client_factory self._client_factory = client_factory or _default_client_factory
self._clients: dict[str, httpx.AsyncClient] = {} self._clients: dict[str, httpx.AsyncClient] = {}
@@ -415,12 +592,29 @@ class OpenAICompatTransport:
overlay: dict[str, Any], overlay: dict[str, Any],
call_id: str, call_id: str,
reasoning_effort: Effort | None, reasoning_effort: Effort | None,
first_token_event: asyncio.Event | None,
content_event: asyncio.Event | None,
response_mode: ResponseMode,
) -> TransportResult: ) -> TransportResult:
"""一次原始调用;HTTP/线路/流式异常按 ARCH §6.2 翻译为领域错误。 """一次原始调用;HTTP/线路/流式异常按 ARCH §6.2 翻译为领域错误。
`reasoning_effort` 是**请求级**档位(`None` = 不表态);它与源级配置的优先级 `reasoning_effort` 是**请求级**档位(`None` = 不表态);它与源级配置的优先级
在 `_build_payload` 里由 `effective_effort` 裁定,本层只负责把它送到。 在 `_build_payload` 里由 `effective_effort` 裁定,本层只负责把它送到。
`first_token_event` 为对冲处置位: `None` = 不观测首 token;仅流式路径
(`_complete_stream`)在首个增量到达时置位,非流式路径收它但永不置位。
`content_event` 为中段沉默对冲处置位(1.3.8): 同款约定,流式路径**每个**
增量 chunk 置位一次,非流式路径收它但永不置位。
""" """
if response_mode not in ("text", "openai"):
raise RequestRejectedError(
"非法 response_mode", source_name=source.name, operation="chat"
)
if response_mode == "openai":
for layer in (source.extra_body, overlay):
if "n" in layer and (type(layer["n"]) is not int or layer["n"] != 1):
raise RequestRejectedError(
"openai 模式仅支持 n=1", source_name=source.name, operation="chat"
)
profile = get_provider(source.provider, registry=self._registry) profile = get_provider(source.provider, registry=self._registry)
try: try:
payload, applied_effort = self._build_payload( payload, applied_effort = self._build_payload(
@@ -444,9 +638,31 @@ class OpenAICompatTransport:
ctx: dict[str, Any] = {"source_name": source.name, "operation": "chat"} ctx: dict[str, Any] = {"source_name": source.name, "operation": "chat"}
try: try:
if stream: if stream:
result = await self._complete_stream(client, url, payload, source, profile) result = await self._complete_stream(
client,
url,
payload,
source,
profile,
first_token_event,
content_event,
response_mode,
)
else: else:
result = await self._complete_once(client, url, payload, source, profile) result = await self._complete_once(
client,
url,
payload,
source,
profile,
first_token_event,
content_event,
response_mode,
)
except TransientError as exc:
if exc.source_name is None:
exc.source_name = source.name
raise
except StreamLivenessTimeout as exc: except StreamLivenessTimeout as exc:
raise TransientError(f"{source.name} 流活性超时({exc.kind})", **ctx) from exc raise TransientError(f"{source.name} 流活性超时({exc.kind})", **ctx) from exc
except httpx.TimeoutException as exc: except httpx.TimeoutException as exc:
@@ -532,6 +748,9 @@ class OpenAICompatTransport:
payload: dict[str, Any], payload: dict[str, Any],
source: SourceConfig, source: SourceConfig,
profile: ProviderProfile, profile: ProviderProfile,
first_token_event: asyncio.Event | None,
content_event: asyncio.Event | None,
response_mode: ResponseMode,
) -> TransportResult: ) -> TransportResult:
started = time.monotonic() started = time.monotonic()
async with client.stream("POST", url, json=payload) as resp: async with client.stream("POST", url, json=payload) as resp:
@@ -541,39 +760,59 @@ class OpenAICompatTransport:
source, resp.status_code, body, resp.headers, operation="chat" source, resp.status_code, body, resp.headers, operation="chat"
) )
sink: dict[str, Any] = {} sink: dict[str, Any] = {}
accumulator = ChatCompletionAccumulator(response_mode=response_mode)
guarded = stream_with_liveness_timeouts( guarded = stream_with_liveness_timeouts(
_iter_sse_deltas(resp.aiter_lines(), sink), _iter_sse_deltas(resp.aiter_lines(), sink, accumulator),
ttft_s=source.ttft_timeout_s or source.timeout_s, ttft_s=source.ttft_timeout_s or source.timeout_s,
inter_token_s=source.inter_token_timeout_s or source.timeout_s, inter_token_s=source.inter_token_timeout_s or source.timeout_s,
total_s=source.timeout_s, total_s=source.timeout_s,
) )
content_parts: list[str] = [] timing = await _consume_deltas(guarded, started, first_token_event, content_event)
thinking_parts: list[str] = [] return self._stream_result(sink, accumulator, timing, source, profile, response_mode)
ttft_ms: float | None = None
last = started def _stream_result(
max_gap = 0.0 self,
async for is_content, text in guarded: sink: dict[str, Any],
now = time.monotonic() accumulator: ChatCompletionAccumulator,
if ttft_ms is None: timing: _StreamTiming,
ttft_ms = (now - started) * 1000 source: SourceConfig,
else: profile: ProviderProfile,
max_gap = max(max_gap, (now - last) * 1000) response_mode: ResponseMode,
last = now ) -> TransportResult:
(content_parts if is_content else thinking_parts).append(text) """流结束后的统一判定(设计 §4.2-§4.3): 完整性 → 结局 → 组装。"""
salvaged = self._check_done(sink, content_parts, thinking_parts, source) self._warn_dialect(source, accumulator.conflicts, accumulator.finish_reason)
content, thinking = self._finalize_text(content_parts, thinking_parts, profile) thinking_parts = timing.reasoning_parts if timing.has_reasoning else timing.alias_parts
self._reject_empty_completion(content, source) content, thinking = self._finalize_text(timing.content_parts, thinking_parts, profile)
prompt, completion, usage_source = _resolve_stream_usage(sink, salvaged) stream_end = _stream_is_complete(
done=bool(sink.get("done")),
finish_reason=accumulator.finish_reason,
usage_after_finish=accumulator.usage_after_finish,
usage_measured=_resolve_usage(sink.get("usage") or {})[2] == "measured",
)
if stream_end is None:
stream_end = self._truncated(timing, thinking_parts, source, response_mode)
message = accumulator.raw_message() if response_mode == "openai" else None
_raise_unless_deliverable(
response_mode, message, content, thinking, accumulator.finish_reason, source
)
completion_result = accumulator.finish() if response_mode == "openai" else None
prompt, completion, usage_source = _resolve_stream_usage(sink, stream_end == "salvaged")
reasoning_tokens = _coerce_reasoning_tokens(sink.get("usage")) reasoning_tokens = _coerce_reasoning_tokens(sink.get("usage"))
stall_ms, stall_count = _stall_from_chunk_times(
timing.chunk_times, source.stall_threshold_ms
)
return TransportResult( return TransportResult(
content=content, content=content,
thinking=thinking, thinking=thinking,
prompt_tokens=prompt, prompt_tokens=prompt,
completion_tokens=completion, completion_tokens=completion,
usage_source=usage_source, usage_source=usage_source,
ttft_ms=ttft_ms, ttft_ms=timing.ttft_ms,
max_inter_token_ms=(max_gap if ttft_ms is not None else None), max_inter_token_ms=(timing.max_gap if timing.ttft_ms is not None else None),
stall_ms=stall_ms,
stall_count=stall_count,
raw={"usage": sink.get("usage")}, raw={"usage": sink.get("usage")},
completion=completion_result,
cached_prompt_tokens=_coerce_cached_tokens(sink.get("usage")), cached_prompt_tokens=_coerce_cached_tokens(sink.get("usage")),
model_reported=_coerce_model_reported(sink.get("model")), model_reported=_coerce_model_reported(sink.get("model")),
reasoning_tokens=reasoning_tokens, reasoning_tokens=reasoning_tokens,
@@ -582,38 +821,56 @@ class OpenAICompatTransport:
thinking_observation=observe_thinking( thinking_observation=observe_thinking(
thinking=thinking, reasoning_tokens=reasoning_tokens thinking=thinking, reasoning_tokens=reasoning_tokens
), ),
finish_reason=accumulator.finish_reason,
stream_end=stream_end,
) )
def _check_done( def _truncated(
self, self,
sink: dict[str, Any], timing: _StreamTiming,
content_parts: list[str],
thinking_parts: list[str], thinking_parts: list[str],
source: SourceConfig, source: SourceConfig,
) -> bool: response_mode: ResponseMode,
"""缺 [DONE] 语义(设计 §6): 零内容恒 retry;有内容按 missing_done 策略。""" ) -> StreamEnd:
if sink.get("done"): """截断流(设计 §4.2): 保真不打捞;文本按 missing_done,零产物恒 retry。"""
return False
ctx: dict[str, Any] = {"source_name": source.name, "operation": "chat"} ctx: dict[str, Any] = {"source_name": source.name, "operation": "chat"}
if not content_parts and not thinking_parts: if response_mode == "openai":
raise TransientError(f"{source.name} missing_done: 保真响应截断,禁止打捞", **ctx)
if not any(timing.content_parts) and not any(thinking_parts):
raise TransientError(f"{source.name} SSE early_eof: 零内容断流", **ctx) raise TransientError(f"{source.name} SSE early_eof: 零内容断流", **ctx)
if source.missing_done == "retry": if source.missing_done == "retry":
raise TransientError(f"{source.name} SSE missing_done: 截断且无 [DONE]", **ctx) raise TransientError(f"{source.name} SSE missing_done: 截断且无 [DONE]", **ctx)
return True return "salvaged"
def _reject_empty_completion(self, content: str, source: SourceConfig) -> None: def _warn_dialect(
"""空补全 → 瞬时错误(2026-07-20 人类裁决,M1 验证发现)。 self, source: SourceConfig, conflicts: frozenset[str], finish_reason: str | None
) -> None:
服务 200 且流程完整([DONE]/usage 正常)但 content 为空——MiniMax 等 """方言告警: 冲突删键与未登记 finish 各按 (源, 路径/值) 只喊一次。"""
网关的间歇异常形态。视为服务抖动: 退避重试/换源,**绝不缓存空响应**; for raw_path in sorted(conflicts):
承 CHS "VLM 零 content"归瞬时的先例(invokers.py:309)。 path = raw_path[:_PATH_WARN_CHARS] # 键名由上游决定,长度同样须有界
""" self._warn_once(
if not content.strip(): source.name, f"field:{path}", f"字段 {path} 合并冲突已删除,保真结果缺该字段"
raise TransientError(
f"{source.name} 空补全(empty_completion): 流程完整但零内容",
source_name=source.name,
operation="chat",
) )
if finish_reason is not None and _classify_finish(finish_reason) == "unknown":
value = finish_reason[:_FINISH_WARN_CHARS]
self._warn_once(
source.name,
f"finish:{value}",
f"未登记的 finish_reason {value!r},按正常结束交付,该值的结果不缓存",
)
def _warn_once(self, source_name: str, key: str, text: str) -> None:
"""有界节流: 键由上游决定,长跑 worker 不能让集合无限增长(设计 §5.4)。"""
if (source_name, key) in self._warned_fields:
return
if len(self._warned_fields) >= _WARN_CAP:
if not self._warn_saturated:
self._warn_saturated = True
logger.warning("方言告警节流集合已饱和({}),后续同类告警省略", _WARN_CAP)
return
self._warned_fields.add((source_name, key))
# 单参数传入 loguru: 文案可能含上游给的花括号(同 `_warn_on_thinking_mismatch`)
logger.warning("源 {} —— {}", source_name, text)
def _finalize_text( def _finalize_text(
self, content_parts: list[str], thinking_parts: list[str], profile: ProviderProfile self, content_parts: list[str], thinking_parts: list[str], profile: ProviderProfile
@@ -633,8 +890,20 @@ class OpenAICompatTransport:
payload: dict[str, Any], payload: dict[str, Any],
source: SourceConfig, source: SourceConfig,
profile: ProviderProfile, profile: ProviderProfile,
first_token_event: asyncio.Event | None,
content_event: asyncio.Event | None,
response_mode: ResponseMode,
) -> TransportResult: ) -> TransportResult:
"""非流式快路径(三项目均无,库新增): 单 JSON 响应,仅 total 超时。""" """非流式快路径(三项目均无,库新增): 单 JSON 响应,仅 total 超时。
接收 `first_token_event` 但**永不置位**: 非流式无中途信号,事件自然退化
为纯时间阈值(对冲只能靠 `hedge_after_s` 触发)。`content_event`(1.3.8)
同款: 接收但**永不置位**——非流式无"中段"概念,stall 两值走
TransportResult 默认 None(排除条款)。
无流完整性可言,直接以 body 的 finish_reason 走结局判定;保真模式**先判定
后构造快照**,否则半截工具在构造时就抛 Transient,到不了 `tool_truncated`。
"""
resp = await client.post(url, json=payload) resp = await client.post(url, json=payload)
if resp.status_code != 200: if resp.status_code != 200:
raise _status_to_error( raise _status_to_error(
@@ -646,16 +915,17 @@ class OpenAICompatTransport:
raise TransientError( raise TransientError(
f"{source.name} 非流式响应非法 JSON", source_name=source.name, operation="chat" f"{source.name} 非流式响应非法 JSON", source_name=source.name, operation="chat"
) from exc ) from exc
choices = body.get("choices") or [] _check_chat_error(body)
if not choices: message, finish = _nonstream_message(body, response_mode, source)
raise TransientError(
f"{source.name} 响应缺 choices", source_name=source.name, operation="chat"
)
message = choices[0].get("message") or {}
content, thinking = self._finalize_text( content, thinking = self._finalize_text(
[message.get("content") or ""], [message.get("reasoning_content") or ""], profile [message.get("content") or ""], [_reasoning_of(message)], profile
)
self._warn_dialect(source, frozenset(), finish)
raw_message = message if response_mode == "openai" else None
_raise_unless_deliverable(response_mode, raw_message, content, thinking, finish, source)
completion_result = (
build_completion(message, body, finish) if response_mode == "openai" else None
) )
self._reject_empty_completion(content, source)
prompt, completion, usage_source = _resolve_usage(body.get("usage") or {}) prompt, completion, usage_source = _resolve_usage(body.get("usage") or {})
reasoning_tokens = _coerce_reasoning_tokens(body.get("usage")) reasoning_tokens = _coerce_reasoning_tokens(body.get("usage"))
return TransportResult( return TransportResult(
@@ -667,6 +937,7 @@ class OpenAICompatTransport:
ttft_ms=None, ttft_ms=None,
max_inter_token_ms=None, max_inter_token_ms=None,
raw={"usage": body.get("usage")}, raw={"usage": body.get("usage")},
completion=completion_result,
cached_prompt_tokens=_coerce_cached_tokens(body.get("usage")), cached_prompt_tokens=_coerce_cached_tokens(body.get("usage")),
model_reported=_coerce_model_reported(body.get("model")), model_reported=_coerce_model_reported(body.get("model")),
reasoning_tokens=reasoning_tokens, reasoning_tokens=reasoning_tokens,
@@ -675,6 +946,7 @@ class OpenAICompatTransport:
thinking_observation=observe_thinking( thinking_observation=observe_thinking(
thinking=thinking, reasoning_tokens=reasoning_tokens thinking=thinking, reasoning_tokens=reasoning_tokens
), ),
finish_reason=finish,
) )
async def aclose(self) -> None: async def aclose(self) -> None:
+455 -6
View File
@@ -4,6 +4,7 @@
fake,字段顺序即公共承诺;新增字段只增不删且必带默认值。 fake,字段顺序即公共承诺;新增字段只增不删且必带默认值。
""" """
import copy
import dataclasses import dataclasses
import json import json
import math import math
@@ -275,13 +276,19 @@ class ThinkingObservation(StrEnum):
UNKNOWN = "unknown" UNKNOWN = "unknown"
CallOperation = Literal["chat", "embed", "recognize_text", "parse_layout"] CallOperation = Literal["chat", "embed", "recognize_text", "parse_layout", "parse_document"]
"""遥测 `operation` 列的值域: **公开方法**四值,由调用点给定。 """遥测 `operation` 列的值域: **公开方法**五值,由调用点给定。
与 `PolyGatewayError.operation`(HTTP 子操作,如 `download_result`)是**两个语义**, 与 `PolyGatewayError.operation`(HTTP 子操作,如 `download_result`)是**两个语义**,
不做自动转换;链路上任何位置都不得读 `exc.operation` 来填本列(设计 §5 I1/I2)。""" 不做自动转换;链路上任何位置都不得读 `exc.operation` 来填本列(设计 §5 I1/I2)。"""
CALL_OPERATIONS: tuple[CallOperation, ...] = ("chat", "embed", "recognize_text", "parse_layout") CALL_OPERATIONS: tuple[CallOperation, ...] = (
"chat",
"embed",
"recognize_text",
"parse_layout",
"parse_document",
)
EventKind = Literal["attempt", "cache_hit", "terminal_failure"] EventKind = Literal["attempt", "cache_hit", "terminal_failure"]
"""一行遥测描述的事件形态;旧行 NULL,不回填。 """一行遥测描述的事件形态;旧行 NULL,不回填。
@@ -317,23 +324,57 @@ class CallStats:
含缓存 IO、退避等待、准入等待、重问、分批与内联记账。 含缓存 IO、退避等待、准入等待、重问、分批与内联记账。
"总耗时减最后一次尝试耗时"**不等于**纯等待(含其他本地工作)。""" "总耗时减最后一次尝试耗时"**不等于**纯等待(含其他本地工作)。"""
hedges: int = 0
"""本次逻辑调用实际并发发出的对冲路数(触发但准入失败静默不计);1.3.6 及以前恒 0。"""
generation_ms: int = 0
"""净生成时间(1.3.8 起重定义,设计 §4.2): 赢家/成功那次 transport 墙钟
− Σ(超阈值中段沉默);仍含 TTFT 与尾部(末 chunk→流结束)。
非流式/增量 chunk<2 无沉默观测、不退减(= 原墙钟,此时 stall 两值 None)。
墙钟还原 = generation_ms + stall_ms。1.3.7 及以前本字段是完整墙钟,
口径变化见 CHANGELOG 行为变更段。"""
hedge_won: bool = False
"""赢家是否为对冲路;无对冲恒 False。"""
stall_ms: int | None = None
"""赢家路超阈值中段沉默总额(净口径归因;None = 非流式/chunk<2/无赢家)。
墙钟 = generation_ms + stall_ms;None 是"无沉默观测",不是 0。"""
stall_count: int | None = None
"""超阈值相邻间隔个数(归因成对报告,Gregg 原则,调研 §B2)。"""
class _CallContext: class _CallContext:
"""私有可变逻辑调用上下文: 只持计数、单调时钟与终态去重位,不做 I/O。 """私有可变逻辑调用上下文: 只持计数、单调时钟与终态去重位,不做 I/O。
**每调用一个实例**的单任务对象: chat 重试、结构化重问、embedding 分批 **每逻辑调用一个实例**,可多任务并发登记(对冲);全部方法无 await,
都在同一任务内串行推进,故计数无需锁。**严禁提升为 client 实例属性** 事件循环内任务安全。**严禁提升为 client 实例属性**
——那会让同一 client 的并发调用互相串掉计数与逻辑 ID(库铁律"纯 asyncio 中立"、 ——那会让同一 client 的并发调用互相串掉计数与逻辑 ID(库铁律"纯 asyncio 中立"、
VT `evolve_llm = llm` 教训的同一形态)。 VT `evolve_llm = llm` 教训的同一形态)。
""" """
__slots__ = ("_attempts", "_now", "_started", "_terminal_claimed", "logical_call_id") __slots__ = (
"_attempts",
"_generation_ms",
"_hedge_won",
"_hedges",
"_now",
"_stall_count",
"_stall_ms",
"_started",
"_terminal_claimed",
"logical_call_id",
)
def __init__(self, *, now: Callable[[], float]) -> None: def __init__(self, *, now: Callable[[], float]) -> None:
self.logical_call_id = str(uuid.uuid4()) self.logical_call_id = str(uuid.uuid4())
self._now = now self._now = now
self._started = now() self._started = now()
self._attempts = 0 self._attempts = 0
self._generation_ms = 0
self._hedges = 0
self._hedge_won = False
self._stall_ms: int | None = None
self._stall_count: int | None = None
self._terminal_claimed = False self._terminal_claimed = False
def register_attempt(self) -> None: def register_attempt(self) -> None:
@@ -344,12 +385,40 @@ class _CallContext:
""" """
self._attempts += 1 self._attempts += 1
def record_generation(
self,
elapsed_ms: int,
*,
accumulate: bool,
stall_ms: int | None = None,
stall_count: int | None = None,
) -> None:
"""chat/OCR 覆盖(结构化重问最后一轮为准);embedding 分批累加。
stall 两值(1.3.8 净口径归因): chat 赢家路传入;embedding/ocr 既有调用
恒 None = "无沉默观测"(不是"沉默为 0")——它们是各自链路的墙钟,无中段
沉默概念。与 generation 同一次登记覆盖,不单独累加。
"""
self._generation_ms = self._generation_ms + elapsed_ms if accumulate else elapsed_ms
self._stall_ms = stall_ms
self._stall_count = stall_count
def register_hedge(self, *, hedge_won: bool) -> None:
"""对冲路实际发出即计数;赢家裁定后一次性登记。"""
self._hedges += 1
self._hedge_won = hedge_won
def snapshot(self) -> CallStats: def snapshot(self) -> CallStats:
"""同步冻结当前快照;**绝不 await**,可多次调用。""" """同步冻结当前快照;**绝不 await**,可多次调用。"""
return CallStats( return CallStats(
logical_call_id=self.logical_call_id, logical_call_id=self.logical_call_id,
attempts=self._attempts, attempts=self._attempts,
total_latency_ms=int((self._now() - self._started) * 1000), total_latency_ms=int((self._now() - self._started) * 1000),
hedges=self._hedges,
generation_ms=self._generation_ms,
hedge_won=self._hedge_won,
stall_ms=self._stall_ms,
stall_count=self._stall_count,
) )
def claim_terminal(self) -> bool: def claim_terminal(self) -> bool:
@@ -360,6 +429,280 @@ class _CallContext:
return True return True
ResponseMode = Literal["text", "openai"]
StreamEnd = Literal["done", "eof_after_finish", "salvaged"]
"""流式结束方式(1.3.10, #31): `done` = 收到 `[DONE]`;`eof_after_finish` = 未发
`[DONE]` 但 finish_reason 与其后的实测 usage 齐全、连接干净关闭;`salvaged` = 文本
模式按 `missing_done=salvage` 打捞的截断结果。非流式/缓存命中/失败行为 `None`。"""
FinishClass = Literal["natural", "truncated", "filtered", "upstream_error", "unknown", "absent"]
@dataclass(frozen=True)
class OpenAIChatCompletion:
"""单 choice 协议结果,构造时快照隔离;嵌套 JSON 仍可变。"""
message: dict[str, Any]
finish_reason: str | None
response_id: str | None
usage: dict[str, Any] | None
def __post_init__(self) -> None:
"""校验协议快照;transport 翻译错误,缓存坏条目按 miss。"""
message = self.message
_check_completion_shape(message, self.finish_reason, self.response_id, self.usage)
json.dumps({"message": message, "usage": self.usage}, allow_nan=False)
object.__setattr__(self, "message", copy.deepcopy(message))
object.__setattr__(self, "usage", copy.deepcopy(self.usage))
def _check_completion_shape(message: Any, finish_reason: Any, response_id: Any, usage: Any) -> None:
"""快照字段形态校验;违约抛 ValueError(由 transport/缓存各自翻译)。"""
if not isinstance(message, dict) or message.get("role") != "assistant":
raise ValueError("completion message 必须含 assistant role")
if not all(_str_or_none(message.get(key)) for key in ("content", "reasoning_content")):
raise ValueError("completion content/reasoning_content 必须为字符串或 null")
if not (_str_or_none(finish_reason) and _str_or_none(response_id)):
raise ValueError("completion 身份/结束原因必须为字符串或 null")
if not isinstance(usage, dict | None):
raise ValueError("completion usage 必须为对象或 null")
if not isinstance(message.get("tool_calls", []), list | None):
raise ValueError("completion tool_calls 必须为数组或 null")
if not _tool_snapshot_ok(message):
raise ValueError("completion tool 身份/函数字段缺失、冲突或非法")
def _str_or_none(value: Any) -> bool:
"""协议字符串字段的形态判据: 字符串或 null。"""
return value is None or isinstance(value, str)
def _tool_ok(tool: Any, seen: set[str]) -> bool:
"""单个工具条目是否满足快照不变式;成立时登记其 id 供查重。"""
if not isinstance(tool, dict):
return False
identity, fn = tool.get("id"), tool.get("function")
ok = (
isinstance(identity, str)
and bool(identity)
and identity not in seen
and tool.get("type") == "function"
and isinstance(fn, dict)
and isinstance(fn.get("name"), str)
and bool(fn["name"])
and isinstance(fn.get("arguments"), str)
)
if ok:
seen.add(identity)
return ok
def _tool_snapshot_ok(message: Mapping[str, Any]) -> bool:
"""工具快照不变式(1.3.9 规则): 缺省/null 视无工具;null 元素跳过。
抽成纯判据而非留在快照构造里: 结局判定(#30)要在构造**之前**知道工具是否
成立——截断的半截工具归 ResultInvalid,构造时抛 Transient 就到不了那一行。
"""
tools = message.get("tool_calls")
if tools is None:
return True
if not isinstance(tools, list):
return False
seen: set[str] = set()
return all(tool is None or _tool_ok(tool, seen) for tool in tools)
_FINISH_CLASSES: Mapping[str, FinishClass] = MappingProxyType(
{
"stop": "natural",
"tool_calls": "natural",
"function_call": "natural",
"length": "truncated",
"content_filter": "filtered",
"sensitive": "filtered", # GLM: 安全审核拦截
"network_error": "upstream_error", # GLM: 模型推理异常
"error": "upstream_error",
}
)
def _classify_finish(value: str | None) -> FinishClass:
"""finish_reason → 类别;大小写敏感(上游原值),未登记非空值为 unknown。"""
if value is None:
return "absent"
return _FINISH_CLASSES.get(value, "unknown")
def _stream_is_complete(
*, done: bool, finish_reason: str | None, usage_after_finish: bool, usage_measured: bool
) -> StreamEnd | None:
"""流完整性(设计 §4.2): None = 截断。
缺 `[DONE]` 时只认"上游声明结束 + 其后到达的实测 usage": 前者证明生成结束,
后者证明流走到了 include_usage 的自然末尾(finish 之前的累计 usage 不算)。
"""
if done:
return "done"
if finish_reason is not None and usage_after_finish and usage_measured:
return "eof_after_finish"
return None
@dataclass(frozen=True)
class _OutputSummary:
"""一次结果的产物摘要(设计 §4.3 定义),判定的唯一输入形态。"""
any_output: bool
body_output: bool
tools_present: bool
tools_ok: bool
null_arguments: bool = False
"""工具快照仅因 `function.arguments` 全程 null 而不成立(无参函数方言,审查 I2)。"""
def _summarize_text(content: str, thinking: str) -> _OutputSummary:
"""文本模式: 产物只看投影正文与思考;空白也算产物,正文须非空白。"""
return _OutputSummary(
any_output=content != "" or thinking != "",
body_output=content.strip() != "",
tools_present=False,
tools_ok=True,
)
def _has_string(value: Any) -> bool:
"""递归查找任一非空字符串片段。"""
if isinstance(value, str):
return value != ""
if isinstance(value, dict):
return any(_has_string(item) for item in value.values())
if isinstance(value, list):
return any(_has_string(item) for item in value)
return False
def _summarize_message(message: Mapping[str, Any], projected_content: str) -> _OutputSummary:
"""保真模式: 任一非空字符串字段(role 除外)或工具条目即产物。"""
tools = message.get("tool_calls")
tools_present = isinstance(tools, list) and any(tool is not None for tool in tools)
tools_ok = _tool_snapshot_ok(message)
fields = {k: v for k, v in message.items() if k not in ("role", "tool_calls")}
return _OutputSummary(
any_output=tools_present or _has_string(fields),
body_output=projected_content.strip() != "" or (tools_present and tools_ok),
tools_present=tools_present,
tools_ok=tools_ok,
null_arguments=not tools_ok and _tool_snapshot_ok(_fill_null_arguments(message)),
)
def _fill_null_arguments(message: Mapping[str, Any]) -> dict[str, Any]:
"""仅供判定: 把 null arguments 视作空串后再验快照,区分"只缺参数"与其他缺陷。
不改写交付值——保真结果原样;此函数的产物只用于分类。
"""
tools = message.get("tool_calls")
if not isinstance(tools, list):
return dict(message)
filled = []
for tool in tools:
fn = tool.get("function") if isinstance(tool, dict) else None
if isinstance(fn, dict) and "arguments" in fn and fn["arguments"] is None:
tool = {**tool, "function": {**fn, "arguments": ""}}
filled.append(tool)
return {**message, "tool_calls": filled}
@dataclass(frozen=True)
class _Verdict:
"""结局判定;`deliver` 为真时另两字段为 None。"""
deliver: bool
error_kind: Literal["transient", "result_invalid"] | None
code: str | None
_DELIVER = _Verdict(True, None, None)
_EMPTY = _Verdict(False, "transient", "empty_completion")
_WITHOUT_CONTENT: Mapping[str, str] = MappingProxyType(
{"truncated": "length_without_content", "filtered": "filtered_without_content"}
)
def _judge_broken(
*, mode: ResponseMode, summary: _OutputSummary, finish: FinishClass
) -> _Verdict | None:
"""行 1-2: 上游自报失败、坏工具——先于产物判断,正文不得掩盖。"""
if finish == "upstream_error":
return _Verdict(False, "transient", "upstream_finish_error")
if mode == "openai" and summary.tools_present and not summary.tools_ok:
if finish == "truncated":
return _Verdict(False, "result_invalid", "tool_truncated")
if summary.null_arguments:
# 无参函数只发 arguments:null 是确定性方言: 重拉同样结果,不重试也不改写
return _Verdict(False, "result_invalid", "tool_arguments_null")
return _Verdict(False, "transient", "tool_incomplete")
return None
def _judge_outcome(*, mode: ResponseMode, summary: _OutputSummary, finish: FinishClass) -> _Verdict:
"""结局分类(设计 §4.3),自上而下首个匹配行生效。
确定性结局(length/过滤)在文本模式归 ResultInvalid 而非 Transient: 重抽只会
再花一份钱,还会把健康源推向熔断;保真模式原样交回,由下游按 finish 决定。
"""
broken = _judge_broken(mode=mode, summary=summary, finish=finish)
if broken is not None:
return broken
if summary.body_output:
return _DELIVER
if finish in _WITHOUT_CONTENT: # 行 3/6/7: 确定性结局
if mode == "openai":
return _DELIVER
return _Verdict(False, "result_invalid", _WITHOUT_CONTENT[finish])
# 行 4/8/9: 零产物恒为抖动;有产物无正文时,只有保真且上游明确声明结束才交回
if summary.any_output and mode == "openai" and finish != "absent":
return _DELIVER
return _EMPTY
def _effective_finish(finish_reason: Any, completion: OpenAIChatCompletion | None) -> str | None:
"""缓存读写共用的 finish 口径: 顶层优先,缺省回退协议快照(1.3.9 条目无顶层)。"""
if finish_reason is None and completion is not None:
return completion.finish_reason
return finish_reason
def _is_cacheable(
*,
mode: ResponseMode,
content: str,
finish_reason: str | None,
stream_end: str | None,
completion: OpenAIChatCompletion | None,
) -> bool:
"""可缓存 ⊂ 可交付(设计 §6): 截断、空正文、非自然结束一律不进 TTL。
缓存回放的是"同一请求的确定答案";length/过滤/未知结束带随机性或不可证明
是自然结束,固化进 TTL 会把一次偶然变成必然(无缓存毒化铁律)。
"""
if stream_end not in (None, "done", "eof_after_finish"):
return False
if _classify_finish(finish_reason) not in ("natural", "absent"):
return False
if mode == "openai":
# 判原文而非投影: 协议原文为空时不信任缓存里的旧投影(1.3.9 同口径)
if completion is None:
return False
raw = completion.message.get("content")
summary = _summarize_message(completion.message, raw if isinstance(raw, str) else "")
# 原文与投影都须有正文: 原文空不信任旧投影;投影空(think 标签剥净)不固化空回答
has_tools = summary.tools_present and summary.tools_ok
return summary.body_output and (content.strip() != "" or has_tools)
return content.strip() != ""
@dataclass(frozen=True) @dataclass(frozen=True)
class LLMResponse: class LLMResponse:
"""一次治理调用的统一响应(与三项目超集兼容,ARCH §5.1)。""" """一次治理调用的统一响应(与三项目超集兼容,ARCH §5.1)。"""
@@ -425,6 +768,15 @@ class LLMResponse:
call_stats: CallStats | None = None call_stats: CallStats | None = None
"""本次**逻辑调用**的统计快照(1.3.5);`None` = 未知,不得读成 0。""" """本次**逻辑调用**的统计快照(1.3.5);`None` = 未知,不得读成 0。"""
completion: OpenAIChatCompletion | None = None
finish_reason: str | None = None
"""上游结束原因原值(1.3.10, #30/#31),两模式同口径;`None` = 未上报或不可观测。"""
stream_end: StreamEnd | None = None
"""流式结束方式(1.3.10, #31),见 `StreamEnd`。与 `usage_source` 分工: 那个答
"账单可信吗",这个答"内容完整吗";`eof_after_finish` 必为 measured。"""
@dataclass(frozen=True) @dataclass(frozen=True)
class ChatRequest: class ChatRequest:
@@ -481,6 +833,8 @@ class ChatRequest:
洋葱各层经 `dataclasses.replace` 派生请求时保留**同一引用**(不是拷贝), 洋葱各层经 `dataclasses.replace` 派生请求时保留**同一引用**(不是拷贝),
重试/重问/分批才能共享同一个逻辑 ID 与计数。""" 重试/重问/分批才能共享同一个逻辑 ID 与计数。"""
response_mode: ResponseMode = "text"
@dataclass(frozen=True) @dataclass(frozen=True)
class Usage: class Usage:
@@ -554,6 +908,26 @@ class TransportResult:
`medium` → 实发 `low`),中间件事后再算一遍必然算成请求档。默认 `None` 是 `medium` → 实发 `low`),中间件事后再算一遍必然算成请求档。默认 `None` 是
"未表态/不注入推理参数"(OCR、embedding 等 transport 沉默即此)。""" "未表态/不注入推理参数"(OCR、embedding 等 transport 沉默即此)。"""
stall_ms: float | None = None
"""超阈值相邻增量 chunk 沉默总额(1.3.8, issue #27;毫秒,未取整——换算唯一处
在 retry 层,设计 §4.2 N8)。
非流式/增量 chunk <2 → `None`("无从观测");观测过但零超阈沉默是 `0.0`,
两者不得混——下游预算公式以 `stall_ms is None` 为分支条件(设计 §4.2 N2)。
尾部(末 chunk → 流结束)不计,归活跃侧(设计 §4.2 N1)。"""
stall_count: float | None = None
"""超阈值相邻间隔个数(归因成对报告,Gregg 原则);值域与 `None` 语义同
`stall_ms`。"""
completion: OpenAIChatCompletion | None = None
finish_reason: str | None = None
"""上游结束原因原值(1.3.10, #30/#31),两模式同口径;`None` = 未上报或不可观测。"""
stream_end: StreamEnd | None = None
"""流式结束方式(1.3.10, #31),见 `StreamEnd`。与 `usage_source` 分工: 那个答
"账单可信吗",这个答"内容完整吗";`eof_after_finish` 必为 measured。"""
@dataclass(frozen=True) @dataclass(frozen=True)
class SourceConfig: class SourceConfig:
@@ -607,10 +981,19 @@ class SourceConfig:
后者对未知值是 fail-closed(按 `error` 处理),不会替配置兜错,漏判的结果 后者对未知值是 fail-closed(按 `error` 处理),不会替配置兜错,漏判的结果
就是 `EFFORT_FALLBAK` 这种拼写错误静默失效。""" 就是 `EFFORT_FALLBAK` 这种拼写错误静默失效。"""
stall_threshold_ms: int = 1000
"""流式中段沉默的计量阈值(1.3.8, issue #27): 相邻增量 chunk 间隔**严格大于**
本值才计入 `TransportResult.stall_ms`/`stall_count`。
纯计量口径,不触发任何动作;触发中段对冲的动作阈值是 scope 级
`HEDGE__STALL_AFTER_S`(设计 §6 两参数分工)。scope 级键
`STREAM__STALL_THRESHOLD_MS` 经装配层 `dataclasses.replace` 分发进本字段。"""
def __post_init__(self) -> None: def __post_init__(self) -> None:
self._validate_identity() self._validate_identity()
self._validate_gates() self._validate_gates()
self._validate_watchdog() self._validate_watchdog()
self._validate_stall_threshold()
self._validate_thinking() self._validate_thinking()
self._freeze_extra_body() self._freeze_extra_body()
@@ -649,6 +1032,17 @@ class SourceConfig:
): ):
raise ValueError("看门狗不变式要求 0 < inter_token < ttft < timeout_s") raise ValueError("看门狗不变式要求 0 < inter_token < ttft < timeout_s")
def _validate_stall_threshold(self) -> None:
# bool 是 int 子类: True/False 会被当成 1/0 静默放行,必须显式拒(P5)
if (
isinstance(self.stall_threshold_ms, bool)
or not isinstance(self.stall_threshold_ms, int)
or self.stall_threshold_ms <= 0
):
raise ValueError(
f"SourceConfig.stall_threshold_ms 必须是正整数: {self.stall_threshold_ms!r}"
)
def _validate_thinking(self) -> None: def _validate_thinking(self) -> None:
"""推理两键的**归一化**、值域与互不矛盾(issue #20 设计 §4.2)。 """推理两键的**归一化**、值域与互不矛盾(issue #20 设计 §4.2)。
@@ -801,6 +1195,10 @@ class OcrLayoutElement:
type 为开放字符串(实测 table/image/text,不枚举锁死——零业务假设); type 为开放字符串(实测 table/image/text,不枚举锁死——零业务假设);
bbox 为 OCR 原生页面坐标 (x1, y1, x2, y2),几何映射留业务侧(D9)。 bbox 为 OCR 原生页面坐标 (x1, y1, x2, y2),几何映射留业务侧(D9)。
注意: `OcrDocumentElement` 继承本类并追加**无默认值**的 `text`。dataclass 要求
无默认字段不得排在带默认字段之后,故日后若给本类追加带默认值的字段,必须先评估
子类——否则 `OcrDocumentElement` 会在导入期抛 `TypeError`(#34 设计 §4.1)。
""" """
type: str type: str
@@ -808,6 +1206,22 @@ class OcrLayoutElement:
page_index: int page_index: int
@dataclass(frozen=True)
class OcrDocumentElement(OcrLayoutElement):
"""文档解析的逐块结果(#34 设计 §4.1): 版面元素 + 该块的文字内容。
`text` 的含义随 `type`,由 `parse_document` 的解析规则固定:
`text`/`title` 为纯文本(行内公式保留 `$…$`);`interline_equation` 为裸 LaTeX
(无 `$$` 定界);`table` 为 HTML 表体;`image` 为空串。服务将来若给出带 caption
/footnote 的嵌套子块,它们作为独立元素紧随父块输出,`type` 取子块类型。
继承而非组合: 文档元素"是"版面元素加内容,按 bbox 裁剪的下游函数可同时处理两者。
代价是 dataclass 的 `__eq__` 要求同类,`OcrDocumentElement != OcrLayoutElement`。
"""
text: str
@dataclass(frozen=True) @dataclass(frozen=True)
class OcrTextResult: class OcrTextResult:
"""一次治理 OCR 文本转录的统一响应(/ocr/text;M3 设计 §3.1)。 """一次治理 OCR 文本转录的统一响应(/ocr/text;M3 设计 §3.1)。
@@ -844,6 +1258,31 @@ class OcrLayoutResult:
"""本次逻辑调用的统计快照(1.3.5);`None` = 未知。""" """本次逻辑调用的统计快照(1.3.5);`None` = 未知。"""
@dataclass(frozen=True)
class OcrDocumentResult:
"""一次治理整份文档解析的统一响应(/parse → ZIP;#34 设计 §4.1)。
**独立定义,不继承 `OcrLayoutResult`**: 父类末尾的 `call_stats` 带默认值,
子类追加无默认字段 `markdown` 会在导入期 `TypeError`。
elements 按服务阅读序排列,页内按页序;page_sizes 按 page_index 索引(零基)。
`markdown` 是服务生成的整篇 Markdown 原文,**含悬空的 `![](images/<sha>.jpg)`
图片引用**——库不外传图片资源。服务端会丢弃脚注、页眉页脚与页码,这些内容
既不在 elements 也不在 markdown 里,库无从补偿。
"""
elements: list[OcrDocumentElement]
page_sizes: list[tuple[float, float]]
markdown: str
source_name: str
usage: Usage
latency_ms: int
call_id: str
raw: dict[str, Any]
call_stats: CallStats | None = None
"""本次逻辑调用的统计快照(1.3.5);`None` = 未知。"""
@dataclass(frozen=True) @dataclass(frozen=True)
class OcrTextTransportResult: class OcrTextTransportResult:
"""transport 单次 /ocr/text 调用产物;治理字段由 OcrClient 补齐。""" """transport 单次 /ocr/text 调用产物;治理字段由 OcrClient 补齐。"""
@@ -861,6 +1300,16 @@ class OcrLayoutTransportResult:
raw: dict[str, Any] raw: dict[str, Any]
@dataclass(frozen=True)
class OcrDocumentTransportResult:
"""transport 单次整份文档两段调用的产物;治理字段由 OcrClient 补齐。"""
elements: list[OcrDocumentElement]
page_sizes: list[tuple[float, float]]
markdown: str
raw: dict[str, Any]
@dataclass(frozen=True) @dataclass(frozen=True)
class EmbeddingTransportResult: class EmbeddingTransportResult:
"""一次原始 embedding 调用的解析结果(M2 设计 §7.2;transport → client)。""" """一次原始 embedding 调用的解析结果(M2 设计 §7.2;transport → client)。"""
+14 -1
View File
@@ -1,5 +1,6 @@
"""测试侧独立 HTTP 取证装配;无环境自读取或成功 SSE 预读。""" """测试侧独立 HTTP 取证装配;无环境自读取或成功 SSE 预读。"""
import asyncio
from collections.abc import AsyncIterator, Iterator, Mapping from collections.abc import AsyncIterator, Iterator, Mapping
from contextlib import AsyncExitStack, asynccontextmanager, contextmanager from contextlib import AsyncExitStack, asynccontextmanager, contextmanager
from contextvars import ContextVar from contextvars import ContextVar
@@ -21,7 +22,13 @@ from polygateway.client import (
) )
from polygateway.providers import get_provider from polygateway.providers import get_provider
from polygateway.transports.openai_compat import OpenAICompatTransport from polygateway.transports.openai_compat import OpenAICompatTransport
from polygateway.types import Effort, EmbeddingTransportResult, SourceConfig, TransportResult from polygateway.types import (
Effort,
EmbeddingTransportResult,
ResponseMode,
SourceConfig,
TransportResult,
)
from tests.live_evidence import ( from tests.live_evidence import (
AttemptEvidence, AttemptEvidence,
HttpEvidence, HttpEvidence,
@@ -290,6 +297,9 @@ class ObservedTransport:
overlay: dict[str, Any], overlay: dict[str, Any],
call_id: str, call_id: str,
reasoning_effort: Effort | None, reasoning_effort: Effort | None,
first_token_event: asyncio.Event | None,
content_event: asyncio.Event | None,
response_mode: ResponseMode,
) -> TransportResult: ) -> TransportResult:
"""与生产端口逐参数同签名。""" """与生产端口逐参数同签名。"""
with self._capture.attempt_context(call_id): with self._capture.attempt_context(call_id):
@@ -301,6 +311,9 @@ class ObservedTransport:
overlay=overlay, overlay=overlay,
call_id=call_id, call_id=call_id,
reasoning_effort=reasoning_effort, reasoning_effort=reasoning_effort,
first_token_event=first_token_event,
content_event=content_event,
response_mode=response_mode,
) )
async def embed( async def embed(
+114
View File
@@ -0,0 +1,114 @@
"""真实网关工具双轮冒烟;复用安全 Markdown 报告,不运行工具副作用。"""
import dataclasses
import json
import os
from pathlib import Path
from uuid import uuid4
import pytest
from dotenv import dotenv_values
from polygateway import GatewayClient, GatewaySettings
from tests.live_evidence import write_live_round
_ENV = {k: v for k, v in {**dotenv_values(".env"), **os.environ}.items() if v is not None}
pytestmark = [pytest.mark.slow, pytest.mark.requires_llm]
@pytest.mark.parametrize("stream", [True, False])
async def test_tools_two_rounds(stream):
"""已配置源中选首源,只改变测试隔离与 raw-only 意图,不收紧生产超时。"""
if not any(k.startswith("LLM__") and k.endswith("__API_KEY") for k in _ENV):
pytest.skip("未配置真实源,未覆盖")
settings = GatewaySettings.from_env("LLM", env=_ENV)
source = dataclasses.replace(settings.sources[0], enable_thinking=None, reasoning_effort=None)
settings = dataclasses.replace(
settings,
sources=(source,),
cache_backend="none",
cache_namespace=None,
cache_ttl_s=None,
telemetry_backend="none",
telemetry_auto_migrate=False,
limiter_backend="memory",
breaker_backend="memory",
hedge_after_s=None,
hedge_stall_after_s=None,
)
tools = [
{
"type": "function",
"function": {
"name": "echo_value",
"description": "Return the supplied value",
"parameters": {
"type": "object",
"properties": {"value": {"type": "string"}},
"required": ["value"],
},
},
}
]
messages = [
{
"role": "user",
"content": "Call echo_value with value hello. After receiving the tool result, reply with hello.",
}
]
status, error_type, counts = "FAIL", None, {}
try:
async with GatewayClient.from_settings(settings) as client:
first = await client.chat(
messages,
stream=stream,
response_mode="openai",
overlay={
"tools": tools,
"tool_choice": {"type": "function", "function": {"name": "echo_value"}},
},
)
completion = first.completion
assert completion is not None and completion.message["role"] == "assistant"
assert completion.response_id and completion.finish_reason == "tool_calls"
calls = completion.message["tool_calls"]
counts["tools"] = len(calls)
assert calls
messages.append(completion.message)
for call in calls:
assert call["function"]["name"] == "echo_value"
arguments = json.loads(call["function"]["arguments"])
assert arguments["value"] == "hello"
messages.append(
{"role": "tool", "tool_call_id": call["id"], "content": arguments["value"]}
)
second = await client.chat(
messages,
stream=stream,
response_mode="openai",
overlay={"tools": tools, "tool_choice": "none"},
)
assert (
second.completion is not None and second.completion.message["role"] == "assistant"
)
assert "hello" in (second.completion.message.get("content") or "").lower()
counts["attempts"] = first.call_stats.attempts + second.call_stats.attempts
status = "PASS"
except Exception as exc:
error_type = type(exc).__name__
raise
finally:
write_live_round(
Path("tests/outputs/issue29/live"),
run_id=uuid4().hex,
matrix_id="tools",
round_index=1,
safe_fields={
"status": status,
"stream": stream,
"error_type": error_type,
"counts": counts,
"planned_rounds": 2,
"completed_rounds": 2 if status == "PASS" else 0,
},
)
@@ -0,0 +1,135 @@
"""MonkeyOCR 整份文档活体验收(#34 计划 T8)。
**成败取决于外部服务当下状态**,按 CLAUDE.md §4.6 一律标 `slow`: 默认套件不收集,发布清单
第 4 步按 diff 交集选跑(`pytest tests/integration/test_monkey_ocr_document_live.py -m slow`)。
服务与其它业务线共用——所以每条用例只发**单份极小文档**、串行、不压测。
文档字节由本文件手写构造(最小合法 PDF,无第三方内容、无新依赖)。
不依赖 `data/soak` 图片语料,故**不**并入带语料 `skipif` 的 `test_monkey_ocr_live.py`。
服务不可达即 FAIL——验收必打真实,不 skip。
"""
import time
from pathlib import Path
import pytest
from polygateway.errors import RequestRejectedError
from polygateway.ocr import OcrClient
pytestmark = pytest.mark.slow
_PRIMARY = "http://10.77.0.20:7866"
_MARKER = "POLYGATEWAY SYNTHETIC SAMPLE"
_OUTPUTS = Path("tests/outputs/monkey_ocr_document")
def _minimal_pdf(text: str) -> bytes:
"""5 个对象的最小合法单页 PDF;xref 偏移按实际字节计算。`text` 不得含括号。"""
stream = f"BT /F1 28 Tf 40 700 Td ({text}) Tj ET".encode()
objects = [
b"<< /Type /Catalog /Pages 2 0 R >>",
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R "
b"/Resources << /Font << /F1 5 0 R >> >> >>",
b"<< /Length %d >>\nstream\n" % len(stream) + stream + b"\nendstream",
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
]
out = bytearray(b"%PDF-1.4\n")
offsets = []
for number, body in enumerate(objects, start=1):
offsets.append(len(out))
out += b"%d 0 obj\n" % number + body + b"\nendobj\n"
xref_at = len(out)
out += b"xref\n0 %d\n0000000000 65535 f \n" % (len(objects) + 1)
for offset in offsets:
out += b"%010d 00000 n \n" % offset
out += b"trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n" % (
len(objects) + 1,
xref_at,
)
return bytes(out)
def _env(**extra: str) -> dict[str, str]:
env = {
"LLM_MAX_RETRIES": "3",
"LLM_RETRY_BASE_DELAY": "1.0",
"LLM_RETRY_MAX_DELAY": "5.0",
# 阈值 2: 坏文档若被误记成源故障,两份就足以开路——下面第三条用例据此判别
"LLM_CIRCUIT_BREAKER_THRESHOLD": "2",
"LLM_CIRCUIT_BREAKER_COOLDOWN": "30",
"PGW_CACHE_BACKEND": "none",
"PGW_TELEMETRY_BACKEND": "none",
"OCR__MONKEY__1__BASE_URL": _PRIMARY,
"OCR__MONKEY__1__API_KEY": "none",
"OCR__MONKEY__1__MODEL": "monkey-ocr",
"OCR__MONKEY__1__TIMEOUT_S": "300",
"OCR__MONKEY__1__MAX_CONCURRENCY": "1", # 与其它业务线共用,串行
# 服务在 LAN,开发机的系统代理会对内网地址回 403
"OCR__MONKEY__1__TRUST_ENV": "false",
}
env.update(extra)
return env
def _record(name: str, lines: list[str]) -> None:
"""结构化 Markdown 证据(CLAUDE.md §4.6);tests/outputs 不提交。"""
_OUTPUTS.mkdir(parents=True, exist_ok=True)
stamp = time.strftime("%Y%m%d_%H%M%S")
(_OUTPUTS / f"{name}_{stamp}.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
class TestLiveDocument:
async def test_parse_document_returns_pages_and_text(self):
async with OcrClient.from_env("OCR", env=_env()) as client:
result = await client.parse_document(_minimal_pdf(_MARKER))
assert len(result.page_sizes) >= 1 and all(w > 0 and h > 0 for w, h in result.page_sizes)
assert result.elements, "合成 PDF 应至少检出一个块"
# 正向断言: 空串不能通过。OCR 对大号印刷体应稳定识别出标记词
assert "SYNTHETIC" in result.markdown.upper()
assert any("SYNTHETIC" in e.text.upper() for e in result.elements)
assert all(e.page_index == 0 for e in result.elements)
_record(
"parse_document",
[
"# parse_document 活体",
f"- source: {result.source_name} latency_ms: {result.latency_ms}",
f"- pages: {len(result.page_sizes)} elements: {len(result.elements)}",
f"- types: {sorted({e.type for e in result.elements})}",
f"- markdown: {result.markdown[:200]!r}",
],
)
async def test_recognize_text_on_pdf_is_not_the_hallucination(self):
"""E4: PDF 以 image.jpg 上传时 `/ocr/text` 返回 success 的幻觉文本。标签修正后应返回全文。"""
async with OcrClient.from_env("OCR", env=_env()) as client:
result = await client.recognize_text(_minimal_pdf(_MARKER))
assert result.text.strip()
assert "single character" not in result.text.lower()
assert "SYNTHETIC" in result.text.upper()
_record(
"recognize_text_pdf", ["# recognize_text(PDF) 活体", f"- text: {result.text[:200]!r}"]
)
async def test_corrupt_documents_do_not_open_the_breaker(self):
"""确定性坏输入归「请求被拒」,熔断记成功不计窗口。
阈值 2: 若坏文档被误记成源故障(旧行为: 500 → Transient → 重试并计熔断),
连续两份就会开路,第三次的有效文档会被 `fail_fast` 拒绝。
"""
corrupt = [b"%PDF-1.4\nnot really a pdf\n", b"%PDF-1.4\n" + b"garbage" * 50]
async with OcrClient.from_env("OCR", env=_env()) as client:
for bad in corrupt:
with pytest.raises(RequestRejectedError) as ei:
await client.parse_document(bad)
assert ei.value.status_code == 500 # 服务有响应: 按「响应即健康」记成功
result = await client.parse_document(_minimal_pdf(_MARKER))
assert result.elements
_record(
"corrupt_documents",
[
"# 坏文档不开熔断 活体",
f"- 两份坏文档均 RequestRejected(500); 随后有效文档成功, elements={len(result.elements)}",
],
)
+24 -4
View File
@@ -69,6 +69,11 @@ _EXPECTED_COLUMNS = [
"error_body", "error_body",
"attempts", "attempts",
"total_latency_ms", "total_latency_ms",
# —— 1.3.8 沉默归因两列(issue #27)——
"stall_ms",
"stall_count",
# —— 1.3.10 结束方式(#31)——
"stream_end",
] ]
# 1.3.5 之前那张表的 27 个物理列(26 个 INSERT 字段 + created_at)。写成固定切片 # 1.3.5 之前那张表的 27 个物理列(26 个 INSERT 字段 + created_at)。写成固定切片
@@ -167,6 +172,11 @@ def _minimal_fields(call_id: str | None = None, **overrides) -> dict[str, object
# 逻辑快照两列只属终态行 # 逻辑快照两列只属终态行
"attempts": None, "attempts": None,
"total_latency_ms": None, "total_latency_ms": None,
# —— 1.3.8 两列: 沉默归因只属流式 chat 成功 attempt 行(issue #27),普通行 NULL ——
"stall_ms": None,
"stall_count": None,
# —— 1.3.10: 结束方式只属流式成功 attempt 行,普通行 NULL ——
"stream_end": None,
} }
fields.update(overrides) fields.update(overrides)
return fields return fields
@@ -769,7 +779,7 @@ class TestCallerDimensionsAcceptance:
"WHERE table_schema = $1 AND table_name = 'llm_calls' ORDER BY ordinal_position", "WHERE table_schema = $1 AND table_name = 'llm_calls' ORDER BY ordinal_position",
schema, schema,
) )
# 22 → 36 个 recorder 字段(加 created_at 共 37 个物理列),且新列追加在末尾 # 22 → 39 个 recorder 字段(加 created_at 共 40 个物理列),且新列追加在末尾
assert [r["column_name"] for r in cols] == _EXPECTED_COLUMNS assert [r["column_name"] for r in cols] == _EXPECTED_COLUMNS
rows = await _fetch( rows = await _fetch(
schema_dsn, schema_dsn,
@@ -1100,7 +1110,7 @@ class TestCallObservabilityColumnsAcceptance:
""" """
async def test_pre_135_table_gains_the_ten_columns_and_old_rows_stay_null(self, pre_135_schema): async def test_pre_135_table_gains_the_ten_columns_and_old_rows_stay_null(self, pre_135_schema):
"""27 列旧表 auto 补齐到 37 列,新行两类取值读得回,**历史行十列一律 NULL**。 """27 列旧表 auto 补齐到 40 列(1.3.5 十列 + 1.3.8 两列 + 1.3.10 一列),新行两类取值读得回,**历史行新增列一律 NULL**。
旧行不回填是本版的明示决策(设计 §5): NULL 表达的是"补列之前根本没记过 旧行不回填是本版的明示决策(设计 §5): NULL 表达的是"补列之前根本没记过
这件事",与任何哨兵值都不是一回事。若哪天有人给这十列加了 DEFAULT,历史行 这件事",与任何哨兵值都不是一回事。若哪天有人给这十列加了 DEFAULT,历史行
@@ -1142,7 +1152,7 @@ class TestCallObservabilityColumnsAcceptance:
"WHERE table_schema = $1 AND table_name = 'llm_calls' ORDER BY ordinal_position", "WHERE table_schema = $1 AND table_name = 'llm_calls' ORDER BY ordinal_position",
schema, schema,
) )
# 26 → 36 个 recorder 字段(加 created_at 共 37 个物理列),新列追加在末尾: # 26 → 39 个 recorder 字段(加 created_at 共 40 个物理列),新列追加在末尾:
# 列序与新建库一致才不会让两条升级路径分叉 # 列序与新建库一致才不会让两条升级路径分叉
assert [r["column_name"] for r in cols] == _EXPECTED_COLUMNS assert [r["column_name"] for r in cols] == _EXPECTED_COLUMNS
@@ -1167,6 +1177,11 @@ class TestCallObservabilityColumnsAcceptance:
"error_body": "upstream said 503", "error_body": "upstream said 503",
"attempts": None, "attempts": None,
"total_latency_ms": None, "total_latency_ms": None,
# 1.3.8 两列: 普通成功 attempt 行无沉默观测 → NULL
"stall_ms": None,
"stall_count": None,
# 1.3.10: 该行未传结束方式 → NULL
"stream_end": None,
} }
assert dict(by_id["term"]) == { assert dict(by_id["term"]) == {
"call_id": "term", "call_id": "term",
@@ -1180,6 +1195,11 @@ class TestCallObservabilityColumnsAcceptance:
"error_body": None, "error_body": None,
"attempts": 3, "attempts": 3,
"total_latency_ms": 4200, "total_latency_ms": 4200,
# 1.3.8 两列: 终态行无 transport 观测 → NULL
"stall_ms": None,
"stall_count": None,
# 1.3.10: 终态行无流可言 → NULL
"stream_end": None,
} }
# 历史行: 十列逐列 NULL(整体比对,漏掉任一列都红) # 历史行: 十列逐列 NULL(整体比对,漏掉任一列都红)
assert dict(by_id["pre135"]) == { assert dict(by_id["pre135"]) == {
@@ -1337,7 +1357,7 @@ class TestPublishedSchemaScript:
await _execute_script(fresh_dsn, script) await _execute_script(fresh_dsn, script)
actual = [r["column_name"] for r in await _fetch(fresh_dsn, _PHYSICAL_COLUMNS_SQL, schema)] actual = [r["column_name"] for r in await _fetch(fresh_dsn, _PHYSICAL_COLUMNS_SQL, schema)]
# 物理列 = 36 个 INSERT 字段 + 库从不显式写的 created_at;对着库常量比,不另抄一份 # 物理列 = 39 个 INSERT 字段 + 库从不显式写的 created_at;对着库常量比,不另抄一份
assert set(actual) == set(COLUMNS) | {"created_at"} assert set(actual) == set(COLUMNS) | {"created_at"}
# 列序也不许漂: 新列必须排在 created_at 之后,否则新建库与 ALTER 升级的列序分叉 # 列序也不许漂: 新列必须排在 created_at 之后,否则新建库与 ALTER 升级的列序分叉
assert actual == _EXPECTED_COLUMNS assert actual == _EXPECTED_COLUMNS
+43
View File
@@ -30,6 +30,13 @@ def _resp(content="cached"):
return LLMResponse(content, "", "m", "p", 1, 2, 30, None, None, False, "orig") return LLMResponse(content, "", "m", "p", 1, 2, 30, None, None, False, "orig")
def _const(response):
async def terminal(request):
return response
return terminal
@pytest.fixture @pytest.fixture
async def redis_cache(): async def redis_cache():
import redis.asyncio as aioredis import redis.asyncio as aioredis
@@ -82,6 +89,23 @@ class TestRealRedisRoundtrip:
assert first.cache_hit is False and second.cache_hit is True assert first.cache_hit is False and second.cache_hit is True
assert calls["n"] == 1 assert calls["n"] == 1
async def test_completion_verdict_fields_roundtrip(self, redis_cache, namespace):
"""1.3.10: finish_reason/stream_end 经真实 Redis 往返;不可缓存结果不落库。"""
mw = CacheMW(
backend=redis_cache, model_fingerprint="m", default_namespace=namespace, ttl_s=60
)
from dataclasses import replace
ok = replace(_resp(), finish_reason="stop", stream_end="eof_after_finish")
first = await mw(ChatRequest(messages=_MSGS), _const(ok))
second = await mw(ChatRequest(messages=_MSGS), _const(ok))
assert first.cache_hit is False and second.cache_hit is True
assert second.finish_reason == "stop" and second.stream_end is None
truncated = replace(_resp(), stream_end="salvaged")
other = [{"role": "user", "content": "integration-salvaged"}]
await mw(ChatRequest(messages=other), _const(truncated))
assert await redis_cache.get(build_cache_key("m", other, namespace, None)) is None
async def test_namespace_isolation_on_shared_instance(self, redis_cache, namespace): async def test_namespace_isolation_on_shared_instance(self, redis_cache, namespace):
"""多项目共用同一 Redis 时,namespace 不同绝不互相命中(防毒化铁律)。""" """多项目共用同一 Redis 时,namespace 不同绝不互相命中(防毒化铁律)。"""
other = f"pgw:test:{uuid.uuid4().hex}" other = f"pgw:test:{uuid.uuid4().hex}"
@@ -172,3 +196,22 @@ class TestClientWithRealRedis:
second = await client.chat(_MSGS) second = await client.chat(_MSGS)
assert first.cache_hit is False and second.cache_hit is True assert first.cache_hit is False and second.cache_hit is True
assert second.content == "hello" assert second.content == "hello"
async def test_real_completion_roundtrip(redis_cache, namespace):
"""真实 Redis 保存完整嵌套协议对象,命中仍为值类型。"""
from dataclasses import replace
from tests.unit.test_openai_completion import accumulate
completion = accumulate().finish()
mw = CacheMW(backend=redis_cache, model_fingerprint="m", default_namespace=namespace, ttl_s=60)
async def terminal(_):
return replace(_resp(), completion=completion)
request = ChatRequest(messages=_MSGS, response_mode="openai")
first = await mw(request, terminal)
second = await mw(request, terminal)
assert second.cache_hit and second.completion == first.completion
assert second.completion is not first.completion
@@ -72,10 +72,25 @@ class ScriptedTransport:
def __init__(self, hang: bool = False): def __init__(self, hang: bool = False):
self.hang = hang self.hang = hang
self.calls: list[str] = [] self.calls: list[str] = []
# 取消用例的确定性窗口(同 test_retry FakeTransport): 进入挂起即置位
self.entered = asyncio.Event()
async def complete(self, *, messages, source, stream, overlay, call_id, reasoning_effort): async def complete(
self,
*,
messages,
source,
stream,
overlay,
call_id,
reasoning_effort,
first_token_event,
content_event,
response_mode,
):
self.calls.append(source.name) self.calls.append(source.name)
if self.hang: if self.hang:
self.entered.set()
await asyncio.Event().wait() await asyncio.Event().wait()
return TransportResult( return TransportResult(
content="ok", content="ok",
@@ -225,7 +240,32 @@ async def test_cancel_in_flight_releases_lease(clients):
assert (await limiter.source_stats("s1")).inflight == 0 assert (await limiter.source_stats("s1")).inflight == 0
# —— 掉线方向(fail-closed 集成证据)—— async def test_cancel_in_flight_keeps_the_reservation(clients):
"""1.3.6 §6.3 S3 在**真实 Redis** 上: 端口已开始、用量未知 → 保留 est 预扣。
内存后端与 Lua 后端的 `settle(delta)` 算术必须同口径——取消时凭空退款
在分布式部署下就是几个 worker 一起击穿 TPM 闸。本用例不改 Lua、不改契约套件。
"""
a_cli, _ = clients
scope = f"t{uuid4().hex[:8]}"
sources = [make_source(max_concurrency=1, tpm=1000, est_tokens=400)]
limiter = _limiter(a_cli, scope, sources, GlobalLimits(0, 0, 0))
transport = ScriptedTransport(hang=True)
client = _client(
scope,
sources,
limiter,
RedisGate(config=_CFG, redis=a_cli, scope=scope),
transport,
)
task = asyncio.create_task(client.chat([{"role": "user", "content": "hi"}]))
await transport.entered.wait()
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
stats = await limiter.source_stats("s1")
assert stats.tpm_used == 400 # 预扣保留, 不回退到 0
assert stats.inflight == 0
async def test_redis_down_admission_fails_closed(): async def test_redis_down_admission_fails_closed():
+81
View File
@@ -0,0 +1,81 @@
"""OCR 文档结果包的合成 fixtures(#34)。
全部**合成**: 保留 2026-10-21 实测的结构骨架(para_blocks 块类型、table/image 的嵌套
`blocks`、span 字段),一切识别文本替换为 `SYNTH-*` 占位——第三方文档内容许可不明、
敏感数据不入库(零业务假设 + P5),真实 ZIP 不提交。
"""
import io
import json
import zipfile
_BBOX = (10.0, 20.0, 110.0, 40.0)
def _span(content="SYNTH-TEXT", type_="text"):
return {"type": type_, "content": content, "bbox": list(_BBOX), "score": 1.0}
def _text_block(content="SYNTH-TEXT", *, type_="text", bbox=_BBOX):
return {
"type": type_,
"bbox": list(bbox),
"index": 0,
"lines": [{"bbox": list(bbox), "spans": [_span(content)]}],
}
def _body(type_, spans, bbox=_BBOX):
return {"type": type_, "bbox": list(bbox), "lines": [{"bbox": list(bbox), "spans": spans}]}
def _table_block(html="<table><tr><td>SYNTH-CELL</td></tr></table>", extra_subs=()):
body = _body("table_body", [{"type": "table", "html": html, "image_path": "synth.jpg"}])
return {"type": "table", "bbox": list(_BBOX), "index": 1, "blocks": [*extra_subs, body]}
def _image_block():
body = _body("image_body", [{"type": "image", "image_path": "synth.jpg"}])
return {"type": "image", "bbox": list(_BBOX), "index": 2, "blocks": [body]}
def _page(blocks=None, *, page_idx=0, page_size=(612.0, 792.0), **extra):
page = {"page_idx": page_idx, "page_size": list(page_size), "discarded_blocks": []}
if blocks is not None:
page["para_blocks"] = blocks
page.update(extra)
return page
def _package(
pages=None,
*,
md="# synth\n",
middle=None,
middle_name="doc_middle.json",
md_name=None,
extra=(),
):
"""组装结果包。`middle` 给原始 bytes 时原样写入;`md_name` 默认由 middle 名配对。"""
if middle is None:
middle = json.dumps({"pdf_info": pages if pages is not None else [_page([])]}).encode()
if md_name is None:
md_name = middle_name[: -len("_middle.json")] + ".md"
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr(middle_name, middle)
if md is not None:
zf.writestr(md_name, md if isinstance(md, bytes) else md.encode())
for name, data in extra:
zf.writestr(name, data)
return buf.getvalue()
def _encrypted_package() -> bytes:
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
info = zipfile.ZipInfo("doc_middle.json")
info.flag_bits |= 0x1 # 加密位:读取时 zipfile 要求密码
zf.writestr(info, b'{"pdf_info": []}')
zf.writestr("doc.md", "x")
return buf.getvalue()
+13 -1
View File
@@ -210,7 +210,19 @@ class ClockAdvancingTransport:
self.clock = clock self.clock = clock
self.calls = [] self.calls = []
async def complete(self, *, messages, source, stream, overlay, call_id, reasoning_effort): async def complete(
self,
*,
messages,
source,
stream,
overlay,
call_id,
reasoning_effort,
first_token_event,
content_event,
response_mode,
):
self.calls.append((source.name, call_id)) self.calls.append((source.name, call_id))
advance, action = self.script.pop(0) advance, action = self.script.pop(0)
self.clock.advance(advance) self.clock.advance(advance)
+236
View File
@@ -15,6 +15,7 @@ from polygateway.types import (
ChatRequest, ChatRequest,
Effort, Effort,
LLMResponse, LLMResponse,
OpenAIChatCompletion,
SourceConfig, SourceConfig,
ThinkingObservation, ThinkingObservation,
) )
@@ -891,3 +892,238 @@ class TestCallStatsNotPoisoned:
without = ChatRequest(messages=_MSGS) without = ChatRequest(messages=_MSGS)
assert with_ctx.cache_namespace == without.cache_namespace assert with_ctx.cache_namespace == without.cache_namespace
assert digest_messages(with_ctx.messages) == digest_messages(without.messages) assert digest_messages(with_ctx.messages) == digest_messages(without.messages)
class TestOpenAICompletionCache:
"""协议缓存不得混模式或把字典冒充公共值对象。"""
def test_mode_isolation_preserves_text_key(self):
assert build_cache_key("m", _MSGS, "proj", None) == build_cache_key(
"m", _MSGS, "proj", None, response_mode="text"
)
assert build_cache_key("m", _MSGS, "proj", None) != build_cache_key(
"m", _MSGS, "proj", None, response_mode="openai"
)
@pytest.mark.parametrize(
"completion",
[
None,
{},
{
"message": {"content": "ok"},
"finish_reason": "stop",
"response_id": None,
"usage": None,
},
],
)
async def test_missing_or_bad_completion_misses(self, completion):
backend = InMemoryCache()
mw = CacheMW(backend=backend, model_fingerprint="m", default_namespace="proj", ttl_s=60)
raw = json.loads(mw._serialize(_resp()))
raw["completion"] = completion
assert (
mw._rehydrate(json.dumps(raw), ChatRequest(messages=_MSGS, response_mode="openai"))
is None
)
async def test_serialization_failure_does_not_fail_call(self):
mw = CacheMW(
backend=InMemoryCache(), model_fingerprint="m", default_namespace="proj", ttl_s=60
)
response = _resp(cost=object())
async def terminal(_):
return response
assert await mw(ChatRequest(messages=_MSGS), terminal) is response
@pytest.mark.parametrize(
"message",
[
{"role": "assistant"},
{"role": "assistant", "content": None, "tool_calls": []},
{"role": "assistant", "content": "", "tool_calls": []},
{"role": "assistant", "content": " \n", "tool_calls": []},
{"role": "assistant", "reasoning_content": "reason only"},
],
)
@pytest.mark.parametrize("cached_content", ["", "stale projection"])
async def test_openai_cache_without_output_misses_and_refills(message, cached_content):
"""空协议产物不能靠旧投影正文伪装成功;miss 后回源并覆写。"""
backend = InMemoryCache()
mw = _mw(backend)
request = ChatRequest(messages=_MSGS, response_mode="openai")
key = build_cache_key("m", _MSGS, "proj", None, response_mode="openai")
bad = _resp(
content=cached_content,
completion=OpenAIChatCompletion(message, "stop", "bad", None),
)
await backend.set(key, mw._serialize(bad), ttl_s=60)
fresh = _resp(
completion=OpenAIChatCompletion(
{"role": "assistant", "content": "cached"}, "stop", "fresh", None
)
)
terminal = _Terminal(fresh)
result = await mw(request, terminal)
assert terminal.calls == 1 and result is fresh and not result.cache_hit
hit = await mw(request, terminal)
assert terminal.calls == 1 and hit.cache_hit
assert hit.completion.response_id == "fresh"
async def test_openai_tool_only_cache_hits_without_origin():
"""有效工具本身是产物,空文本投影不妨碍命中。"""
backend = InMemoryCache()
mw = _mw(backend)
completion = OpenAIChatCompletion(
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call-1",
"type": "function",
"function": {"name": "echo", "arguments": "{}"},
}
],
},
"tool_calls",
"tool-response",
None,
)
request = ChatRequest(messages=_MSGS, response_mode="openai")
key = build_cache_key("m", _MSGS, "proj", None, response_mode="openai")
await backend.set(key, mw._serialize(_resp(content="", completion=completion)), ttl_s=60)
terminal = _Terminal(_resp())
hit = await mw(request, terminal)
assert hit.cache_hit and terminal.calls == 0
assert hit.completion == completion
# —— 1.3.10(#30/#31): 可缓存 ⊂ 可交付,读写共用同一判据(设计 §6)——
_OK_COMPLETION = OpenAIChatCompletion({"role": "assistant", "content": "ok"}, "stop", "up", None)
_THINK_LENGTH = OpenAIChatCompletion(
{"role": "assistant", "reasoning_content": "t"}, "length", "up", None
)
_THINK_STOP = OpenAIChatCompletion(
{"role": "assistant", "reasoning_content": "t"}, "stop", "up", None
)
@pytest.mark.parametrize(
("mode", "response"),
[
("text", _resp(stream_end="salvaged")),
("text", _resp(finish_reason="length")),
("text", _resp(finish_reason="content_filter")),
("text", _resp(finish_reason="end_turn")),
("openai", _resp(content="", finish_reason="length", completion=_THINK_LENGTH)),
("openai", _resp(content="", finish_reason="stop", completion=_THINK_STOP)),
],
)
async def test_uncacheable_results_are_delivered_but_not_written(mode, response):
"""截断/空正文/非自然结束: 照常交回,但不写缓存,下次回源。"""
backend = InMemoryCache()
mw = _mw(backend)
terminal = _Terminal(response)
request = ChatRequest(messages=_MSGS, response_mode=mode)
assert (await mw(request, terminal)) is response
assert (await mw(request, terminal)).cache_hit is False
assert terminal.calls == 2 and not backend._store
def _stored(mode, **fields):
"""直接注入一条缓存原文(模拟旧版本或别的进程写入)。"""
backend = InMemoryCache()
data = dataclasses.asdict(_resp(**fields))
data.pop("structured_data", None)
data.pop("call_stats", None)
key = build_cache_key("m", _MSGS, "proj", None, response_mode=mode)
backend._store[key] = (float("inf"), json.dumps(data))
return backend, key
async def _hit(backend, mode):
terminal = _Terminal(_resp(content="fresh", completion=_OK_COMPLETION, finish_reason="stop"))
response = await _mw(backend)(ChatRequest(messages=_MSGS, response_mode=mode), terminal)
return response, terminal.calls
@pytest.mark.parametrize(
("mode", "fields"),
[
("text", {"stream_end": "salvaged"}),
("text", {"stream_end": "bogus"}),
("text", {"finish_reason": "length"}),
("text", {"finish_reason": 5}),
("openai", {"content": "", "completion": _THINK_LENGTH, "finish_reason": "length"}),
],
)
async def test_injected_uncacheable_entries_read_as_miss(mode, fields):
"""读路径对存储原值判定(覆盖之前): 不可缓存即 miss 回源。"""
backend, _ = _stored(mode, **fields)
response, calls = await _hit(backend, mode)
assert response.cache_hit is False and calls == 1
async def test_legacy_fidelity_length_entry_falls_back_to_completion_finish():
"""1.3.9 保真条目无顶层 finish: 回退 completion.finish_reason 识别为不可缓存。"""
completion = OpenAIChatCompletion({"role": "assistant", "content": "a"}, "length", None, None)
backend, key = _stored("openai", completion=completion)
data = json.loads(backend._store[key][1])
for name in ("finish_reason", "stream_end"):
data.pop(name)
backend._store[key] = (float("inf"), json.dumps(data))
response, calls = await _hit(backend, "openai")
assert response.cache_hit is False and calls == 1
async def test_legacy_text_entry_without_new_fields_still_hits():
"""1.3.9 文本条目无新键: 仍命中;命中 stream_end=None。"""
backend, key = _stored("text")
data = json.loads(backend._store[key][1])
for name in ("finish_reason", "stream_end"):
data.pop(name)
backend._store[key] = (float("inf"), json.dumps(data))
response, calls = await _hit(backend, "text")
assert response.cache_hit is True and calls == 0
assert response.stream_end is None and response.finish_reason is None
async def test_hit_resets_stream_end_and_keeps_effective_finish():
"""命中: stream_end 置 None;finish_reason 取判定所用值(顶层缺省时回退)。"""
backend, key = _stored("openai", completion=_OK_COMPLETION, stream_end="eof_after_finish")
data = json.loads(backend._store[key][1])
data.pop("finish_reason")
backend._store[key] = (float("inf"), json.dumps(data))
response, calls = await _hit(backend, "openai")
assert response.cache_hit is True and calls == 0
assert response.stream_end is None and response.finish_reason == "stop"
async def test_new_fields_roundtrip_through_cache():
"""两新字段随序列化往返;命中只重置 stream_end。"""
backend = InMemoryCache()
mw = _mw(backend)
terminal = _Terminal(_resp(finish_reason="stop", stream_end="eof_after_finish"))
await mw(ChatRequest(messages=_MSGS), terminal)
stored = json.loads(next(iter(backend._store.values()))[1])
assert stored["finish_reason"] == "stop" and stored["stream_end"] == "eof_after_finish"
hit = await mw(ChatRequest(messages=_MSGS), terminal)
assert hit.cache_hit is True and hit.finish_reason == "stop" and hit.stream_end is None
async def test_fidelity_think_only_projection_not_cached():
"""审查 I1: qwen think 标签剥离后投影为空的保真结果不写缓存。"""
completion = OpenAIChatCompletion(
{"role": "assistant", "content": "<think>x</think>"}, "stop", "up", None
)
response = _resp(content="", thinking="x", finish_reason="stop", completion=completion)
backend = InMemoryCache()
await _mw(backend)(ChatRequest(messages=_MSGS, response_mode="openai"), _Terminal(response))
assert not backend._store
+320
View File
@@ -1,8 +1,10 @@
"""GatewayClient 装配与端到端(fake 后端 + MockTransport)测试(设计 §2.4)。""" """GatewayClient 装配与端到端(fake 后端 + MockTransport)测试(设计 §2.4)。"""
import asyncio import asyncio
import gc
import json import json
import sys import sys
import warnings
from pathlib import Path from pathlib import Path
import httpx import httpx
@@ -10,10 +12,12 @@ import pytest
from polygateway import ( from polygateway import (
AllSourcesExhausted, AllSourcesExhausted,
CallDeadlineExceeded,
GatewayClient, GatewayClient,
GatewaySettings, GatewaySettings,
RequestRejectedError, RequestRejectedError,
ResultInvalidError, ResultInvalidError,
TransientError,
gather_bounded, gather_bounded,
) )
from polygateway.backends.memory.breaker import InMemoryGate from polygateway.backends.memory.breaker import InMemoryGate
@@ -33,6 +37,10 @@ from polygateway.types import (
SourceConfig, SourceConfig,
) )
# 复用 RetryMW 那份可编程 fake transport(含确定性 `entered` 窗口),不再造第二份;
# `tests/unit/test_backpressure.py:34` 已是同款复用
from tests.unit.test_retry import FakeTransport, _ok
_REPO = Path(__file__).resolve().parents[2] _REPO = Path(__file__).resolve().parents[2]
_ENV = { _ENV = {
@@ -136,6 +144,102 @@ class TestChatEndToEnd:
await client.chat([{"role": "user", "content": "hi"}], structured="json") await client.chat([{"role": "user", "content": "hi"}], structured="json")
class TestHedgeParams:
"""对冲两个 keyword-only 参数的 client 入口校验(issue #24 H4;计划批次 H)。
编排行为本身见 tests/unit/test_hedge.py;这里只钉装配面: 入口即校、
值域/交叉守卫与 settings 共用同一份 `check_hedge_assembly`。
"""
def test_client_hedge_params_entry_validation(self):
# 值域: 0/负/非有限当场 ValueError(不经 settings 那道守卫)
with pytest.raises(ValueError, match=r"GatewayClient\(hedge_after_s"):
_client(hedge_after_s=0)
with pytest.raises(ValueError, match="hedge_max_extra"):
_client(hedge_max_extra=0)
with pytest.raises(ValueError, match="hedge_max_extra"):
_client(hedge_max_extra=True) # bool 不是 int 档位
# 交叉: 阈值 ≥ 源 timeout_s(10)= 对冲永不可能触发
with pytest.raises(ValueError, match="timeout_s"):
_client(hedge_after_s=99.0)
# 合法值透传到 RetryMW(单源 scope 的装配 warning 是预期噪音,不断言)
client = _client(hedge_after_s=0.05)
assert client._hedge_after_s == 0.05
assert client._terminal._hedge_after_s == 0.05
# 未启用(缺省): 行为逐字等于 1.3.6
assert _client()._terminal._hedge_after_s is None
class TestGenerationMsClient:
"""裸生成时间的 client 级口径(1.3.7 批次 C2/F)。
`_ScriptedGenClockTransport` 在每次 transport 调用内推进注入钟,
使"时间花在哪"可断言(同 test_backpressure.ClockAdvancingTransport 范式)。
"""
_MSG = [{"role": "user", "content": "hi"}]
async def test_generation_ms_structured_last_round_wins(self):
"""结构化重问覆盖而非累加: 首轮坏 JSON 推进 1s,重问轮推进 0.25s → 250。
推进量取二进制可精确表示值: int 截断下非精确值会因浮点误差少 1ms。
"""
from pydantic import BaseModel
class Answer(BaseModel):
answer: int
clock = _StatsClock()
transport = _ScriptedGenClockTransport(
[_ok("not json at all"), _ok('{"answer": 1}')], [1.0, 0.25], clock
)
async with _client(transport=transport, structured_max_retries=1, now=clock) as client:
resp = await client.chat(self._MSG, structured=Answer)
assert resp.content == '{"answer": 1}' and len(transport.calls) == 2
assert resp.call_stats is not None
assert resp.call_stats.attempts == 2
assert resp.call_stats.generation_ms == 250
async def test_cache_hit_generation_ms_zero(self):
"""缓存命中不产生 transport 调用: generation_ms 恒 0(0 是实测,非"未知")。"""
client = _client(cache=InMemoryCache(), cache_namespace="proj", cache_ttl_s=3600)
async with client:
first = await client.chat(self._MSG)
second = await client.chat(self._MSG)
assert first.cache_hit is False and second.cache_hit is True
assert second.call_stats is not None
assert second.call_stats.generation_ms == 0
assert second.call_stats.attempts == 0
class _ScriptedGenClockTransport:
"""脚本化假 transport: 每次成功调用在返回前按脚本推进注入钟(批次 C2/F)。"""
def __init__(self, results, advances, clock):
self._results = list(results)
self._advances = list(advances)
self._clock = clock
self.calls = []
async def complete(
self,
*,
messages,
source,
stream,
overlay,
call_id,
reasoning_effort,
first_token_event,
content_event,
response_mode,
):
self.calls.append(call_id)
result = self._results.pop(0)
self._clock.advance(self._advances.pop(0))
return result
class TestSamplingOverlay: class TestSamplingOverlay:
"""调用级采样参数入口(issue #4 Task 3)。""" """调用级采样参数入口(issue #4 Task 3)。"""
@@ -1717,3 +1821,219 @@ class TestTerminalEmitDegradation:
error=AllSourcesExhausted(scope="llm", reason="retry_exhausted", retry_after_s=1.0), error=AllSourcesExhausted(scope="llm", reason="retry_exhausted", retry_after_s=1.0),
operation="chat", operation="chat",
) )
class _ClockJumpTransport:
"""假 transport: 只推进**注入钟**,真实墙钟几乎不走。
用于把"期限读哪只钟"与"统计读哪只钟"两件事分开断言。
"""
def __init__(self, clock, *, jump):
self._clock = clock
self._jump = jump
self.calls = []
async def complete(
self,
*,
messages,
source,
stream,
overlay,
call_id,
reasoning_effort,
first_token_event,
content_event,
response_mode,
):
self.calls.append(call_id)
self._clock.advance(self._jump)
return _ok()
class _SlowRecorder:
"""假 recorder: 每写一行真实等待一段,用于量化"清理不被期限截断"。"""
def __init__(self, delay=0.15):
self._delay = delay
self.rows = []
async def record_llm_call(self, **fields):
await asyncio.sleep(self._delay)
self.rows.append(fields)
class _SlowSetCache:
"""假缓存后端: `set` 慢于期限,用于构造"已产出、已计费的成功被丢弃"。"""
def __init__(self, delay=0.5):
self._delay = delay
self.data = {}
self.sets = 0
async def get(self, key):
return self.data.get(key)
async def set(self, key, value, ttl_s):
self.sets += 1
await asyncio.sleep(self._delay)
self.data[key] = value
class TestChatCallDeadline:
"""chat 链路的期限覆盖面与到期代价(计划 §5 批次 D/D2)。
真实事件循环时钟: 期限 0.05s 对被治理的等待(退避 5s、轮询 300s、慢 IO 0.5s)
有 10 倍以上余量,故不标 slow。
"""
_MSG = [{"role": "user", "content": "hi"}]
_DEADLINE = 0.05
def _rows(self, recorder, kind):
return [r for r in recorder.rows if r["event_kind"] == kind]
# —— 批次 D: 期限落点覆盖面 ——
async def test_deadline_fires_during_backoff_sleep(self):
"""退避 sleep 是等待的大头(429 序列可睡到小时级),期限必须能在它中间落地。"""
transport = FakeTransport([TransientError("boom", operation="chat"), _ok()])
async with _client(transport=transport, retry=RetryPolicy(3, 5.0, 30.0)) as client:
with pytest.raises(CallDeadlineExceeded) as exc:
await client.chat(self._MSG, call_deadline_s=self._DEADLINE)
assert exc.value.scope == "llm" and exc.value.deadline_s == self._DEADLINE
# 第二次尝试还压在 5s 退避里,期限确实落在 sleep 上而非 transport 上
assert len(transport.calls) == 1
async def test_deadline_fires_while_queued_for_quota(self):
"""准入排队(配额满轮询)是第二类长等待: 一次 transport 都没打出去也要能到期。"""
source = _source(tpm=1, est_tokens=1000) # 预扣量恒超本源 TPM → 六闸永不放行
limiter = InMemoryLimiter(
scope="llm", sources={source.name: source}, global_limits=GlobalLimits(0, 0, 0)
)
transport = FakeTransport([_ok()])
async with _client([source], transport=transport, limiter=limiter) as client:
with pytest.raises(CallDeadlineExceeded):
await client.chat(self._MSG, call_deadline_s=self._DEADLINE)
assert transport.calls == [] # 期限落在轮询里,尝试从未开始
async def test_deadline_fires_during_structured_re_ask(self):
"""结构化重问共享同一份期限: 阶梯不得按轮数各起一份,否则期限被放大 N 倍。
窗口用"第三轮挂起"构造而非 sleep 猜时长——前两轮瞬时返回坏 JSON,
期限只可能落在第三轮上,断言因此与机器负载无关。
"""
from pydantic import BaseModel
class Answer(BaseModel):
answer: int
transport = FakeTransport([_ok("not json at all"), _ok("not json at all"), "hang"])
async with _client(
transport=transport, structured_max_retries=5, structured_strategy=JsonRepairStrategy()
) as client:
with pytest.raises(CallDeadlineExceeded):
await client.chat(self._MSG, structured=Answer, call_deadline_s=0.05)
# 到期发生在第三轮: 期限确实跨过了两次重问,而不是在首轮就截断
assert len(transport.calls) == 3
# —— 批次 D2: 到期代价 ——
async def test_expiry_writes_one_terminal_row_and_a_cancelled_attempt(self):
"""到期恰好一条终态行 + 被取消的 attempt 行,两行同一 logical_call_id。"""
recorder = _MemoryRecorder()
transport = FakeTransport(["hang"])
async with _client(transport=transport, telemetry=recorder) as client:
with pytest.raises(CallDeadlineExceeded):
await client.chat(self._MSG, call_deadline_s=self._DEADLINE)
attempts = self._rows(recorder, "attempt")
terminals = self._rows(recorder, "terminal_failure")
assert len(terminals) == 1
assert terminals[0]["error_type"] == "CallDeadlineExceeded"
assert len(attempts) == 1 and attempts[0]["error"] == "cancelled"
assert attempts[0]["logical_call_id"] == terminals[0]["logical_call_id"]
# 零新增遥测列: 期限终态行的列集合与既有失败路径的终态行逐字相同
baseline = _MemoryRecorder()
async with _client(
handler=lambda request: httpx.Response(400, json={"error": {"message": "bad"}}),
telemetry=baseline,
) as client:
with pytest.raises(RequestRejectedError):
await client.chat(self._MSG)
assert set(terminals[0]) == set(self._rows(baseline, "terminal_failure")[0])
async def test_cleanup_is_not_cut_short_by_the_expiry(self):
"""返回时刻 = 期限 + 清理耗时: 只断下界(> 期限 × 2),不断上界。"""
recorder = _SlowRecorder(delay=0.15) # attempt 行与终态行各付一次
transport = FakeTransport(["hang"])
loop = asyncio.get_running_loop()
started = loop.time()
async with _client(transport=transport, telemetry=recorder) as client:
with pytest.raises(CallDeadlineExceeded):
await client.chat(self._MSG, call_deadline_s=self._DEADLINE)
elapsed = loop.time() - started
assert len(recorder.rows) == 2 # 清理照常写完两行,没被期限截断
assert elapsed > self._DEADLINE * 2, f"清理疑似被截断: {elapsed}s"
async def test_expiry_discards_a_success_that_was_already_billed(self):
"""到期 ≠ 未产出、未计费: transport 已成功一次,结果仍被丢弃。"""
transport = FakeTransport([_ok()])
cache = _SlowSetCache(delay=0.5) # 写缓存慢于期限 → 到期落在成功之后
async with _client(
transport=transport, cache=cache, cache_namespace="proj", cache_ttl_s=600
) as client:
with pytest.raises(CallDeadlineExceeded):
await client.chat(self._MSG, call_deadline_s=self._DEADLINE)
assert len(transport.calls) == 1 # 上游已经产出并计费
assert cache.sets == 1 and cache.data == {} # 结果既没回给调用方也没落缓存
# —— 批次 E: 注入钟与期限正交 ——
async def test_injected_clock_jump_does_not_trigger_the_deadline(self):
"""期限只认真实墙钟: 注入钟跳 10^6 秒也不该凭空到期(不换算绝对截止时刻)。"""
clock = _StatsClock()
transport = _ClockJumpTransport(clock, jump=1_000_000.0)
async with _client(transport=transport, now=clock) as client:
resp = await client.chat(self._MSG, call_deadline_s=5.0)
assert resp.content == "ok"
# 而统计仍逐字读注入钟(10^6 s = 10^9 ms),两只钟各司其职
assert resp.call_stats is not None
assert resp.call_stats.total_latency_ms == 1_000_000_000
async def test_expiry_latency_still_reads_the_injected_clock(self):
"""期限由真实钟触发,终态行的耗时仍取自注入钟(真实耗时只有几十毫秒)。"""
clock = _StatsClock()
recorder = _TickingRecorder(clock, tick=0.5)
transport = FakeTransport(["hang"])
async with _client(transport=transport, telemetry=recorder, now=clock) as client:
with pytest.raises(CallDeadlineExceeded):
await client.chat(self._MSG, call_deadline_s=self._DEADLINE)
terminal = [r for r in recorder.rows if r["event_kind"] == "terminal_failure"][0]
assert terminal["total_latency_ms"] == 500 # attempt 行那一次 tick,不是真实的 ~50ms
class TestChatCallDeadlineEntryGuards:
"""per-call 入口校验的两条硬红线(计划 §3.4/§5 批次 E)。"""
_MSG = [{"role": "user", "content": "hi"}]
async def test_illegal_per_call_value_leaves_no_un_awaited_coroutine(self):
"""校验先于构造 awaitable: 否则非法值抛错时遗留未 await 的协程(资源不释放)。"""
transport = FakeTransport([_ok()])
async with _client(transport=transport) as client:
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
with pytest.raises(ValueError, match=r"chat\(call_deadline_s"):
await client.chat(self._MSG, call_deadline_s=0)
gc.collect() # 未 await 的协程在回收时才发 RuntimeWarning
assert [w for w in caught if "never awaited" in str(w.message)] == []
assert transport.calls == []
async def test_per_call_none_inherits_the_assembled_deadline(self):
"""`None` = 继承装配值(不提供"本次关闭"): 装配了期限就照样到期。"""
transport = FakeTransport(["hang"])
async with _client(transport=transport, call_deadline_s=0.05) as client:
with pytest.raises(CallDeadlineExceeded):
await client.chat(self._MSG)
+453
View File
@@ -0,0 +1,453 @@
"""统一完成判定在 transport 两条路径上的落点(#30/#31,设计 §4.2-§4.4、§5.4)。
假 SSE 按 magpie/glm 实测形态二次构造: 末块 finish_reason + usage 后干净关闭、
不发 [DONE];以及只有思考的 length 截断、null/未知字段等方言。
"""
import asyncio
import json
import httpx
import pytest
from loguru import logger
from polygateway.errors import AllSourcesExhausted, ResultInvalidError, TransientError
from polygateway.transports.openai_compat import OpenAICompatTransport
from tests.unit.test_openai_compat import _complete, _source, _sse_stream, _transport_for
_USAGE = {"prompt_tokens": 11, "completion_tokens": 7}
_TOOL = {
"index": 0,
"id": "call_1",
"type": "function",
"function": {"name": "lookup", "arguments": '{"q": 1}'},
}
def _f(delta=None, finish=None, usage=None, choices=True):
"""单 choice 帧;`choices=False` 构造 usage-only 帧。"""
body = {"id": "up-1"}
if choices:
choice = {"index": 0, "delta": delta or {}}
if finish is not None:
choice["finish_reason"] = finish
body["choices"] = [choice]
else:
body["choices"] = []
if usage is not None:
body["usage"] = usage
return f"data: {json.dumps(body)}\n\n"
def _assistant(**delta):
return _f({"role": "assistant", **delta})
async def _run(frames, *, done, mode="text", **source_kw):
transport = _transport_for(lambda _: _sse_stream(*frames, done=done))
try:
return await _complete(transport, _source(**source_kw), response_mode=mode)
finally:
await transport.aclose()
async def _run_once(body, *, mode="text"):
transport = _transport_for(lambda _: httpx.Response(200, json=body))
try:
return await _complete(transport, _source(), stream=False, response_mode=mode)
finally:
await transport.aclose()
# —— #31: 缺 [DONE] 但 finish + 其后实测 usage 完整 ——
@pytest.mark.parametrize("mode", ["text", "openai"])
@pytest.mark.parametrize("finish", ["stop", "length"])
async def test_eof_after_finish_with_usage_succeeds(mode, finish):
"""验收 1: 末块 finish + usage 后干净关闭 → 成功、measured、eof_after_finish。"""
frames = [_assistant(content="hel"), _f({"content": "lo"}, finish=finish, usage=_USAGE)]
result = await _run(frames, done=False, mode=mode)
assert result.content == "hello" and result.stream_end == "eof_after_finish"
assert result.usage_source == "measured" and result.finish_reason == finish
assert (result.prompt_tokens, result.completion_tokens) == (11, 7)
if mode == "openai":
assert result.completion.finish_reason == finish
async def test_eof_after_finish_usage_only_frame_and_tools():
"""验收 2: tool_calls 结束、usage 在其后的 usage-only 帧 → 工具完整交回。"""
frames = [
_assistant(content=None, tool_calls=[_TOOL]),
_f({}, finish="tool_calls"),
_f(choices=False, usage=_USAGE),
]
result = await _run(frames, done=False, mode="openai")
tool = result.completion.message["tool_calls"][0]
assert tool["function"] == {"name": "lookup", "arguments": '{"q": 1}'}
assert result.stream_end == "eof_after_finish" and result.usage_source == "measured"
@pytest.mark.parametrize(
"frames",
[
[_assistant(content="partial"), _f(choices=False, usage=_USAGE)], # 验收 3: 无 finish
[_assistant(content="partial"), _f({}, finish="stop")], # 验收 4: 无 usage
[_assistant(content="a", **{}), _f({}, usage=_USAGE), _f({}, finish="stop")], # usage 早
[_assistant(content="a"), _f({}, finish="stop", usage={})], # usage 不可用
],
)
async def test_incomplete_eof_still_fails(frames):
"""截断不放宽: 保真仍不打捞,文本 retry 档仍抛。"""
with pytest.raises(TransientError, match="missing_done"):
await _run(frames, done=False, mode="openai", missing_done="salvage")
with pytest.raises(TransientError, match="missing_done"):
await _run(frames, done=False)
async def test_text_salvage_marks_stream_end():
"""文本 salvage 打捞 → stream_end=salvaged,usage 降为 estimated。"""
frames = [_assistant(content="partial"), _f(choices=False, usage=_USAGE)]
result = await _run(frames, done=False, missing_done="salvage")
assert result.stream_end == "salvaged" and result.usage_source == "estimated"
async def test_read_error_mid_stream_fails():
"""验收 5: 读取中途 RemoteProtocolError → 失败,不当干净 EOF。"""
class Broken(httpx.AsyncByteStream):
async def __aiter__(self):
yield _f({"role": "assistant", "content": "a"}, finish="stop", usage=_USAGE).encode()
raise httpx.RemoteProtocolError("peer closed")
async def aclose(self):
return None
transport = _transport_for(lambda _: httpx.Response(200, stream=Broken()))
try:
with pytest.raises(TransientError, match="网络错误"):
await _complete(transport, _source(), response_mode="openai")
finally:
await transport.aclose()
@pytest.mark.parametrize("mode", ["text", "openai"])
async def test_done_stream_marks_done(mode):
"""验收 6: 带 [DONE] 的正常流行为不变,stream_end=done。"""
result = await _run([_assistant(content="ok"), _f({}, "stop", _USAGE)], done=True, mode=mode)
assert result.stream_end == "done" and result.finish_reason == "stop"
@pytest.mark.parametrize("done", [True, False])
@pytest.mark.parametrize("mode", ["text", "openai"])
async def test_upstream_error_finish_is_transient(done, mode):
"""GLM network_error: 上游自报失败,有正文也不交付。"""
frames = [_assistant(content="half"), _f({}, finish="network_error", usage=_USAGE)]
with pytest.raises(TransientError, match="upstream_finish_error"):
await _run(frames, done=done, mode=mode)
# —— #30 与结局表(设计 §4.3)——
_THINK = [_assistant(reasoning_content="long thought")]
@pytest.mark.parametrize("done", [True, False])
async def test_openai_reasoning_only_length_delivered(done):
"""#30 验收: 只有思考 + length → 交回,content 空,思考完整。"""
result = await _run([*_THINK, _f({}, "length", _USAGE)], done=done, mode="openai")
message = result.completion.message
assert message.get("content") in (None, "") and message["reasoning_content"] == "long thought"
assert result.finish_reason == "length" and result.thinking == "long thought"
@pytest.mark.parametrize(
("frames", "finish", "openai_ok", "text_error"),
[
(_THINK, "length", True, (ResultInvalidError, "length_without_content")),
([_assistant()], "length", True, (ResultInvalidError, "length_without_content")),
(_THINK, "content_filter", True, (ResultInvalidError, "filtered_without_content")),
([_assistant()], "sensitive", True, (ResultInvalidError, "filtered_without_content")),
(_THINK, "stop", True, (TransientError, "empty_completion")),
([_assistant(refusal="no")], "stop", True, (TransientError, "empty_completion")),
([_assistant()], "stop", False, (TransientError, "empty_completion")),
],
)
async def test_outcome_rows_stream(frames, finish, openai_ok, text_error):
"""行 3/4/6/7/8: 两模式的分叉。"""
full = [*frames, _f({}, finish, _USAGE)]
if openai_ok:
assert (await _run(full, done=True, mode="openai")).finish_reason == finish
else:
with pytest.raises(TransientError, match="empty_completion"):
await _run(full, done=True, mode="openai")
error, code = text_error
with pytest.raises(error, match=code) as caught:
await _run(full, done=True)
assert caught.type is error # ResultInvalid 不得被 Transient 子类掩盖
@pytest.mark.parametrize("mode", ["text", "openai"])
async def test_reasoning_only_without_finish_is_transient(mode):
"""行 9: 只靠 DONE 收尾、无 finish → 无法区分抖动与结局,维持瞬时。"""
with pytest.raises(TransientError, match="empty_completion"):
await _run([*_THINK, _f(choices=False, usage=_USAGE)], done=True, mode=mode)
async def test_openai_whitespace_content_stop_delivered():
"""正文只有空白 + stop: 保真交回(#30 一-2)。"""
result = await _run(
[_assistant(content=" "), _f({}, "stop", _USAGE)], done=True, mode="openai"
)
assert result.completion.message["content"] == " "
async def test_truncated_tool_is_result_invalid():
"""行 2: length 截断的半截工具(缺 name)→ ResultInvalid tool_truncated。"""
broken = {"index": 0, "id": "c", "type": "function", "function": {"arguments": '{"q'}}
frames = [_assistant(tool_calls=[broken]), _f({}, "length", _USAGE)]
with pytest.raises(ResultInvalidError, match="tool_truncated"):
await _run(frames, done=True, mode="openai")
async def test_content_cannot_mask_broken_tool():
"""行 2: 正文非空 + 坏工具 + stop → Transient tool_incomplete。"""
broken = {"index": 0, "id": "c", "type": "function", "function": {"arguments": "{}"}}
frames = [_assistant(content="text", tool_calls=[broken]), _f({}, "stop", _USAGE)]
with pytest.raises(TransientError, match="tool_incomplete"):
await _run(frames, done=True, mode="openai")
async def test_null_refusal_and_tool_calls_stream():
"""#30 验收二: delta 带 refusal:null 与 tool_calls:null → 正常交回。"""
frames = [_assistant(content="ok", refusal=None, tool_calls=None), _f({}, "stop", _USAGE)]
result = await _run(frames, done=True, mode="openai")
assert result.completion.message["content"] == "ok"
# —— 非流式镜像 ——
def _body(message, finish):
choice = {"index": 0, "message": {"role": "assistant", **message}}
if finish is not None:
choice["finish_reason"] = finish
return {"id": "up", "choices": [choice], "usage": _USAGE}
async def test_nonstream_null_tool_calls_and_refusal():
"""非流式 message 带 tool_calls:null + refusal:null → 成功。"""
body = _body({"content": "hi", "tool_calls": None, "refusal": None}, "stop")
result = await _run_once(body, mode="openai")
assert result.completion.message["tool_calls"] is None and result.stream_end is None
@pytest.mark.parametrize(
("message", "finish", "openai_expect", "text_expect"),
[
(
{"reasoning_content": "t"},
"length",
None,
(ResultInvalidError, "length_without_content"),
),
({"content": ""}, "content_filter", None, (ResultInvalidError, "filtered_without_content")),
({"reasoning_content": "t"}, "stop", None, (TransientError, "empty_completion")),
({"reasoning_content": "t"}, None, (TransientError, "empty_completion"), None),
({"content": "a"}, "network_error", (TransientError, "upstream_finish_error"), None),
({"content": "a"}, "length", None, None),
],
)
async def test_outcome_rows_nonstream(message, finish, openai_expect, text_expect):
"""非流式跳过流完整性,直接以 body finish 走结局表。"""
body = _body(message, finish)
for mode, expect in (("openai", openai_expect), ("text", text_expect or openai_expect)):
if expect is None:
result = await _run_once(body, mode=mode)
assert result.finish_reason == finish and result.stream_end is None
else:
with pytest.raises(expect[0], match=expect[1]) as caught:
await _run_once(body, mode=mode)
assert caught.type is expect[0]
async def test_nonstream_truncated_tool_is_result_invalid():
"""非流式先判定后构造快照: 缺 name + length → tool_truncated 而非构造期 Transient。"""
tool = {"id": "c", "type": "function", "function": {"arguments": "{"}}
with pytest.raises(ResultInvalidError, match="tool_truncated"):
await _run_once(_body({"tool_calls": [tool]}, "length"), mode="openai")
# —— reasoning 别名(设计 §4.4)——
async def test_reasoning_alias_projects_thinking():
"""只有 reasoning 字段: 文本 thinking 取别名;非空 reasoning_content 优先。"""
alias = await _run(
[_assistant(reasoning="r1"), _f({"content": "a"}, "stop", _USAGE)], done=True
)
assert alias.thinking == "r1"
both = [
_assistant(reasoning="r1", reasoning_content="rc"),
_f({"content": "a"}, "stop", _USAGE),
]
assert (await _run(both, done=True)).thinking == "rc"
empty_rc = [_assistant(reasoning="r1", reasoning_content=""), _f({"content": "a"}, "stop")]
assert (await _run(empty_rc, done=True)).thinking == "r1"
nonstream = await _run_once(_body({"content": "a", "reasoning": "nr"}, "stop"))
assert nonstream.thinking == "nr"
async def test_reasoning_alias_keeps_ttft_alive():
"""只有 reasoning 的思考期刷新活性,不被 TTFT 看门狗误杀。"""
class Slow(httpx.AsyncByteStream):
async def __aiter__(self):
yield _assistant().encode()
for _ in range(5): # 0.5s 只有 reasoning,远超 ttft,但每帧间隔 < inter_token
yield _f({"reasoning": "t"}).encode()
await asyncio.sleep(0.1)
yield _f({"content": "answer"}, "stop", _USAGE).encode()
yield b"data: [DONE]\n\n"
async def aclose(self):
return None
transport = _transport_for(lambda _: httpx.Response(200, stream=Slow()))
try:
source = _source(ttft_timeout_s=0.3, inter_token_timeout_s=0.25)
result = await _complete(transport, source)
assert result.content == "answer" and result.thinking == "ttttt"
finally:
await transport.aclose()
# —— 有界告警节流(设计 §5.4)——
def _sink():
records = []
handle = logger.add(lambda m: records.append(m.record["message"]), level="WARNING")
return records, handle
async def test_conflict_and_unknown_finish_warn_once():
"""同源同路径冲突、同一未知 finish 值各只告警一次,不回显字段值。"""
frames = [
_assistant(content="a", ext=1),
_f({"ext": 2}),
_f({}, "end_turn", _USAGE),
]
transport = _transport_for(lambda _: _sse_stream(*frames))
records, handle = _sink()
try:
for _ in range(2):
result = await _complete(transport, _source(), response_mode="openai")
assert "ext" not in result.completion.message
finally:
logger.remove(handle)
await transport.aclose()
conflict = [r for r in records if "delta.ext" in r]
unknown = [r for r in records if "end_turn" in r]
assert len(conflict) == 1 and len(unknown) == 1 and "不缓存" in unknown[0]
async def test_warning_set_is_bounded():
"""节流集合饱和后只记一次饱和告警,不再增长。"""
transport = OpenAICompatTransport()
transport._warned_fields.update((f"s{i}", "k") for i in range(1024))
records, handle = _sink()
try:
transport._warn_once("qwen_1", "field:x", "a")
transport._warn_once("qwen_1", "field:y", "b")
finally:
logger.remove(handle)
await transport.aclose()
assert len(transport._warned_fields) == 1024
assert sum("饱和" in r for r in records) == 1
# —— 治理链: 确定性结局不重试、不计熔断 ——
async def test_text_length_without_content_not_retried_nor_breaker_failure():
"""文本 length 无正文 → ResultInvalid 直抛,attempts=1,熔断不记失败。"""
from tests.unit.test_client import _client
calls = []
def handler(_):
calls.append(1)
return _sse_stream(*_THINK, _f({}, "length", _USAGE))
client = _client(handler=handler)
failures = []
original = client._breaker_backend.record_failure
async def spy(*args, **kwargs):
failures.append(args)
return await original(*args, **kwargs)
client._breaker_backend.record_failure = spy
try:
with pytest.raises(ResultInvalidError, match="length_without_content"):
await client.chat([{"role": "user", "content": "hi"}])
finally:
await client._transport.aclose()
assert len(calls) == 1 and failures == []
async def test_openai_reasoning_length_single_attempt_via_client():
"""#30 验收经治理链: attempts=1;#31 形态经治理链不再重试到耗尽。"""
from tests.unit.test_client import _client
calls = []
def handler(_):
calls.append(1)
return _sse_stream(*_THINK, _f({}, "length", _USAGE), done=False)
client = _client(handler=handler)
try:
response = await client.chat([{"role": "user", "content": "hi"}], response_mode="openai")
except AllSourcesExhausted: # pragma: no cover - 失败即回归
pytest.fail("reasoning-only length 不应重试到耗尽")
finally:
await client._transport.aclose()
assert len(calls) == 1 and response.call_stats.attempts == 1
assert response.stream_end == "eof_after_finish" and response.finish_reason == "length"
async def test_eof_after_finish_stop_single_attempt_via_client():
"""#31 验收 1 经治理链: stop + usage 无 DONE,attempts=1、不重试到耗尽。"""
from tests.unit.test_client import _client
calls = []
def handler(_):
calls.append(1)
return _sse_stream(_assistant(content="ok"), _f({}, "stop", _USAGE), done=False)
client = _client(handler=handler)
try:
response = await client.chat([{"role": "user", "content": "hi"}])
finally:
await client._transport.aclose()
assert len(calls) == 1 and response.call_stats.attempts == 1
assert response.stream_end == "eof_after_finish" and response.usage_source == "measured"
async def test_text_salvage_with_length_partial_content_delivered():
"""文本 salvage + length + 部分正文(finish 后无 usage): 交付、salvaged。"""
frames = [_assistant(content="part"), _f({}, finish="length")]
result = await _run(frames, done=False, missing_done="salvage")
assert result.content == "part" and result.stream_end == "salvaged"
assert result.finish_reason == "length"
async def test_null_arguments_tool_is_result_invalid_stream():
"""审查 I2: 无参函数 arguments 全程 null → ResultInvalid,不重试。"""
tool = {"index": 0, "id": "c", "type": "function", "function": {"name": "f", "arguments": None}}
frames = [_assistant(tool_calls=[tool]), _f({}, "tool_calls", _USAGE)]
with pytest.raises(ResultInvalidError, match="tool_arguments_null"):
await _run(frames, done=True, mode="openai")
+265
View File
@@ -0,0 +1,265 @@
"""统一完成判定(#30/#31): 内核纯函数真值表。"""
import pytest
from polygateway.types import (
LLMResponse,
OpenAIChatCompletion,
TransportResult,
_classify_finish,
_effective_finish,
_is_cacheable,
_judge_outcome,
_OutputSummary,
_stream_is_complete,
_summarize_message,
_summarize_text,
_tool_snapshot_ok,
)
_TOOL = {"id": "t1", "type": "function", "function": {"name": "f", "arguments": "{}"}}
@pytest.mark.parametrize(
("value", "expected"),
[
("stop", "natural"),
("tool_calls", "natural"),
("function_call", "natural"),
("length", "truncated"),
("content_filter", "filtered"),
("sensitive", "filtered"),
("network_error", "upstream_error"),
("error", "upstream_error"),
("end_turn", "unknown"),
("STOP", "unknown"),
(None, "absent"),
],
)
def test_classify_finish(value, expected):
assert _classify_finish(value) == expected
@pytest.mark.parametrize(
("done", "finish", "after", "measured", "expected"),
[
(True, None, False, False, "done"),
(True, "stop", True, True, "done"),
(False, "stop", True, True, "eof_after_finish"),
(False, "length", True, True, "eof_after_finish"),
(False, None, True, True, None), # 无 finish 即截断
(False, "stop", False, True, None), # usage 早于 finish(累计 usage)
(False, "stop", True, False, None), # finish 后 usage 不可用
],
)
def test_stream_is_complete(done, finish, after, measured, expected):
got = _stream_is_complete(
done=done, finish_reason=finish, usage_after_finish=after, usage_measured=measured
)
assert got == expected
def test_tool_snapshot_ok():
assert _tool_snapshot_ok({"role": "assistant"})
assert _tool_snapshot_ok({"tool_calls": None})
assert _tool_snapshot_ok({"tool_calls": [None, _TOOL]})
assert not _tool_snapshot_ok({"tool_calls": {}})
assert not _tool_snapshot_ok({"tool_calls": [{"id": "t1", "type": "function"}]})
assert not _tool_snapshot_ok({"tool_calls": [_TOOL, _TOOL]}) # 重复 id
def test_summarize_text():
assert _summarize_text("", "") == _OutputSummary(False, False, False, True)
assert _summarize_text(" ", "") == _OutputSummary(True, False, False, True)
assert _summarize_text("", "think") == _OutputSummary(True, False, False, True)
assert _summarize_text("hi", "") == _OutputSummary(True, True, False, True)
@pytest.mark.parametrize(
("message", "projected", "expected"),
[
({"role": "assistant"}, "", _OutputSummary(False, False, False, True)),
({"role": "assistant", "content": None}, "", _OutputSummary(False, False, False, True)),
({"role": "assistant", "refusal": "no"}, "", _OutputSummary(True, False, False, True)),
(
{"role": "assistant", "reasoning_content": "r"},
"",
_OutputSummary(True, False, False, True),
),
({"role": "assistant", "content": " "}, " ", _OutputSummary(True, False, False, True)),
(
{"role": "assistant", "x": {"y": ["", "z"]}},
"",
_OutputSummary(True, False, False, True),
),
(
{"role": "assistant", "tool_calls": [None]},
"",
_OutputSummary(False, False, False, True),
),
(
{"role": "assistant", "tool_calls": [_TOOL]},
"",
_OutputSummary(True, True, True, True),
),
(
{"role": "assistant", "tool_calls": [{"id": "t"}]},
"",
_OutputSummary(True, False, True, False),
),
({"role": "assistant", "content": "a"}, "a", _OutputSummary(True, True, False, True)),
],
)
def test_summarize_message(message, projected, expected):
assert _summarize_message(message, projected) == expected
_NONE = _OutputSummary(False, False, False, True)
_THINK = _OutputSummary(True, False, False, True)
_BODY = _OutputSummary(True, True, False, True)
_BAD_TOOL = _OutputSummary(True, True, True, False)
@pytest.mark.parametrize(
("mode", "summary", "finish", "expected"),
[
# 行 1: upstream_error 优先于一切
("openai", _BODY, "upstream_error", ("transient", "upstream_finish_error")),
("text", _BODY, "upstream_error", ("transient", "upstream_finish_error")),
# 行 2: 坏工具(仅保真),正文不得掩盖
("openai", _BAD_TOOL, "truncated", ("result_invalid", "tool_truncated")),
("openai", _BAD_TOOL, "natural", ("transient", "tool_incomplete")),
("text", _BAD_TOOL, "natural", None),
# 行 3: 无产物 + truncated/filtered
("openai", _NONE, "truncated", None),
("openai", _NONE, "filtered", None),
("text", _NONE, "truncated", ("result_invalid", "length_without_content")),
("text", _NONE, "filtered", ("result_invalid", "filtered_without_content")),
# 行 4: 零增量抖动(2026-07-20 裁决)
("openai", _NONE, "natural", ("transient", "empty_completion")),
("openai", _NONE, "absent", ("transient", "empty_completion")),
("openai", _NONE, "unknown", ("transient", "empty_completion")),
("text", _NONE, "absent", ("transient", "empty_completion")),
# 行 5: 有正文
("openai", _BODY, "truncated", None),
("text", _BODY, "filtered", None),
("text", _BODY, "absent", None),
# 行 6-9: 有产物无正文
("openai", _THINK, "truncated", None),
("text", _THINK, "truncated", ("result_invalid", "length_without_content")),
("openai", _THINK, "filtered", None),
("text", _THINK, "filtered", ("result_invalid", "filtered_without_content")),
("openai", _THINK, "natural", None),
("openai", _THINK, "unknown", None),
("text", _THINK, "natural", ("transient", "empty_completion")),
("openai", _THINK, "absent", ("transient", "empty_completion")),
("text", _THINK, "absent", ("transient", "empty_completion")),
],
)
def test_judge_outcome_rows(mode, summary, finish, expected):
verdict = _judge_outcome(mode=mode, summary=summary, finish=finish)
if expected is None:
assert verdict.deliver and verdict.error_kind is None and verdict.code is None
else:
assert not verdict.deliver
assert (verdict.error_kind, verdict.code) == expected
def _completion(content="a", finish="stop", tools=None):
message = {"role": "assistant", "content": content}
if tools is not None:
message["tool_calls"] = tools
return OpenAIChatCompletion(message, finish, None, None)
@pytest.mark.parametrize(
("kwargs", "expected"),
[
({"mode": "text", "content": "a", "finish_reason": "stop", "stream_end": "done"}, True),
({"mode": "text", "content": "a", "finish_reason": None, "stream_end": None}, True),
(
{
"mode": "text",
"content": "a",
"finish_reason": "stop",
"stream_end": "eof_after_finish",
},
True,
),
({"mode": "text", "content": "a", "finish_reason": None, "stream_end": "salvaged"}, False),
({"mode": "text", "content": " ", "finish_reason": "stop", "stream_end": "done"}, False),
({"mode": "text", "content": "a", "finish_reason": "length", "stream_end": None}, False),
({"mode": "text", "content": "a", "finish_reason": "sensitive", "stream_end": None}, False),
({"mode": "text", "content": "a", "finish_reason": "end_turn", "stream_end": None}, False),
({"mode": "text", "content": "a", "finish_reason": "stop", "stream_end": "bogus"}, False),
],
)
def test_is_cacheable_text(kwargs, expected):
assert _is_cacheable(completion=None, **kwargs) is expected
def test_is_cacheable_openai():
ok = _completion()
assert _is_cacheable(
mode="openai", content="a", finish_reason="stop", stream_end="done", completion=ok
)
assert not _is_cacheable(
mode="openai", content="a", finish_reason="stop", stream_end="done", completion=None
)
# 原文为空且无工具: 不信任旧投影
assert not _is_cacheable(
mode="openai",
content="a",
finish_reason="stop",
stream_end="done",
completion=_completion(content=""),
)
tool_only = _completion(content=None, finish="tool_calls", tools=[_TOOL])
assert _is_cacheable(
mode="openai", content="", finish_reason="tool_calls", stream_end=None, completion=tool_only
)
def test_effective_finish():
assert _effective_finish("stop", _completion(finish="length")) == "stop"
assert _effective_finish(None, _completion(finish="length")) == "length"
assert _effective_finish(None, None) is None
def test_completion_accepts_null_tool_calls():
snap = OpenAIChatCompletion({"role": "assistant", "tool_calls": None}, "stop", None, None)
assert "tool_calls" in snap.message and snap.message["tool_calls"] is None
with pytest.raises(ValueError):
OpenAIChatCompletion({"role": "assistant", "tool_calls": {}}, "stop", None, None)
def test_response_fields_default_none():
result = TransportResult("a", "", 0, 0, "unavailable", None, None, {})
assert result.finish_reason is None and result.stream_end is None
response = LLMResponse("a", "", "m", "p", 0, 0, 0, None, None, False, "c")
assert response.finish_reason is None and response.stream_end is None
def test_stream_end_exported():
import polygateway
assert "StreamEnd" in polygateway.__all__
def test_openai_cacheable_requires_projected_body():
"""审查 I1: think 标签剥离后投影为空,即使原文非空也不可缓存。"""
think_only = _completion(content="<think>x</think>")
assert not _is_cacheable(
mode="openai", content="", finish_reason="stop", stream_end="done", completion=think_only
)
def test_null_arguments_only_tool_is_result_invalid():
"""审查 I2: 无参函数 arguments 全程 null(其余齐全)→ 确定性方言,ResultInvalid。"""
tool = {"id": "t1", "type": "function", "function": {"name": "f", "arguments": None}}
summary = _summarize_message({"role": "assistant", "tool_calls": [tool]}, "")
assert summary.null_arguments and not summary.tools_ok
verdict = _judge_outcome(mode="openai", summary=summary, finish="natural")
assert (verdict.error_kind, verdict.code) == ("result_invalid", "tool_arguments_null")
broken = {"id": "t1", "type": "function", "function": {"arguments": None}}
assert not _summarize_message({"role": "assistant", "tool_calls": [broken]}, "").null_arguments
+406
View File
@@ -1041,3 +1041,409 @@ def test_live_unknown_wire_assembly_is_local_only():
GatewayClient.from_settings( GatewayClient.from_settings(
dataclasses.replace(settings, sources=(source,)), registry=register_provider(mystery) dataclasses.replace(settings, sources=(source,)), registry=register_provider(mystery)
) )
class TestCallDeadlineConfig:
"""`{SCOPE}__CALL_DEADLINE_S` 与三个 client 入口参数的值域四条路(issue #22)。"""
def test_key_unset_means_disabled(self):
assert GatewaySettings.from_env("LLM", env=_env()).call_deadline_s is None
def test_env_key_parsed(self):
s = GatewaySettings.from_env("LLM", env=_env(**{"LLM__CALL_DEADLINE_S": "30"}))
assert s.call_deadline_s == 30.0
@pytest.mark.parametrize("bad", ["0", "-1", "nan", "inf", "abc"])
def test_env_illegal_value_reports_the_actual_key_name(self, bad):
"""origin 必须是实际命中的 env 键名,多 scope 部署里才定位得到是哪个键。"""
with pytest.raises(ValueError, match="LLM__CALL_DEADLINE_S"):
GatewaySettings.from_env("LLM", env=_env(**{"LLM__CALL_DEADLINE_S": bad}))
def test_direct_construction_is_guarded(self):
base = GatewaySettings.from_env("LLM", env=_env())
with pytest.raises(ValueError, match="GatewaySettings.call_deadline_s"):
dataclasses.replace(base, call_deadline_s=0)
def test_plain_constructor_call_is_guarded_too(self):
"""`dataclasses.replace` 与直接构造是两条路: 守卫在 `__post_init__` 才两条都盖住。
只在 `from_env` 里校验的话,直接 `GatewaySettings(...)` 装配的下游(测试/高级
注入路径,CLAUDE.md §4.5 的第二条装配路)会把非法期限一路带到第一次调用才炸。
"""
base = GatewaySettings.from_env("LLM", env=_env())
fields = {f.name: getattr(base, f.name) for f in dataclasses.fields(base)}
with pytest.raises(ValueError, match="GatewaySettings.call_deadline_s"):
GatewaySettings(**{**fields, "call_deadline_s": float("inf")})
# 合法值走同一条路不受影响(守卫对合法值是幂等空操作)
assert GatewaySettings(**{**fields, "call_deadline_s": 7}).call_deadline_s == 7.0
def test_replace_with_legal_value_is_idempotent(self):
base = GatewaySettings.from_env("LLM", env=_env())
assert dataclasses.replace(base, call_deadline_s=5).call_deadline_s == 5.0
def test_deadline_shorter_than_timeout_is_legal(self):
"""期限短于单次 timeout_s 是调用方的合法选择,不做跨字段耦合校验。"""
s = GatewaySettings.from_env("LLM", env=_env(**{"LLM__CALL_DEADLINE_S": "1"}))
assert s.call_deadline_s == 1.0 and s.sources[0].timeout_s == 120.0
def test_from_settings_propagates_to_client(self):
s = GatewaySettings.from_env("LLM", env=_env(**{"LLM__CALL_DEADLINE_S": "12"}))
assert GatewayClient.from_settings(s)._call_deadline_s == 12.0
def test_client_init_validates_at_entry(self):
"""三个 client 的 `__init__` 直传非法值也当场报错(不经 settings 那道守卫)。"""
from tests.unit.test_client import _client
with pytest.raises(ValueError, match=r"GatewayClient\(call_deadline_s"):
_client(call_deadline_s=0)
class TestHedgeConfig:
"""`{SCOPE}__HEDGE__AFTER_S`/`{SCOPE}__HEDGE__MAX_EXTRA` 两键与装配守卫(issue #24 H4)。
对冲默认关闭: 键未设 = None/1,行为逐字等于 1.3.6。守卫四路覆盖
(env/直接构造/dataclasses.replace/client 直传),单一定义点是
`config.check_hedge_assembly`。
"""
def _two_source_env(self, **overrides):
"""双源 env(同 provider 避免注册表依赖): 隔离单源 warning 的干扰。"""
return _env(
**{
"LLM__QWEN__2__BASE_URL": "https://gw-b.example/v1",
"LLM__QWEN__2__API_KEY": "sk-b",
"LLM__QWEN__2__MODEL": "qwen-plus",
"LLM__QWEN__2__TIMEOUT_S": "90",
**overrides,
}
)
def test_hedge_keys_from_env_skip_source_loader(self):
"""两键为 3 段键,天然不被 `_load_sources` 当源字段;`HEDGE` 进保留段防 4 段撞名。"""
env = self._two_source_env(**{"LLM__HEDGE__AFTER_S": "8", "LLM__HEDGE__MAX_EXTRA": "2"})
with _captured_warnings(): # max_extra>1 的 v1 单路 warning, 本用例不断言它
s = GatewaySettings.from_env("LLM", env=env)
assert s.hedge_after_s == 8.0
assert s.hedge_max_extra == 2
assert {src.name for src in s.sources} == {"qwen_1", "qwen_2"} # HEDGE 键未造源
# `HEDGE` 在保留段: `LLM__HEDGE__1__*` 四段键不得造出一个名为 hedge_1 的源
env_collision = _env(
**{
"LLM__HEDGE__1__BASE_URL": "https://gw-c.example/v1",
"LLM__HEDGE__1__API_KEY": "sk-c",
"LLM__HEDGE__1__MODEL": "m-c",
"LLM__HEDGE__1__TIMEOUT_S": "60",
}
)
s2 = GatewaySettings.from_env("LLM", env=env_collision)
assert [src.name for src in s2.sources] == ["qwen_1"]
def test_hedge_keys_unset_mean_disabled(self):
"""默认关闭: 两键未设 = None/1,且不产生任何 warning。"""
with _captured_warnings() as warnings:
s = GatewaySettings.from_env("LLM", env=_env())
assert s.hedge_after_s is None and s.hedge_max_extra == 1
assert not warnings
def test_hedge_after_s_domain_four_paths(self):
"""非法值四条装配路全部当场 ValueError(消息须定位得到是哪个键/参数)。"""
from tests.unit.test_client import _client
# 路 1: env(origin 是实际命中的键名)
with pytest.raises(ValueError, match="LLM__HEDGE__AFTER_S"):
GatewaySettings.from_env("LLM", env=_env(**{"LLM__HEDGE__AFTER_S": "0"}))
with pytest.raises(ValueError, match="LLM__HEDGE__AFTER_S"):
GatewaySettings.from_env("LLM", env=_env(**{"LLM__HEDGE__AFTER_S": "abc"}))
base = GatewaySettings.from_env("LLM", env=_env())
# 路 2: 直接构造
fields = {f.name: getattr(base, f.name) for f in dataclasses.fields(base)}
with pytest.raises(ValueError, match="hedge_after_s"):
GatewaySettings(**{**fields, "hedge_after_s": float("nan")})
# 路 3: dataclasses.replace
with pytest.raises(ValueError, match="hedge_after_s"):
dataclasses.replace(base, hedge_after_s=-1)
# 路 4: client 直传(不经 settings 那道守卫)
with pytest.raises(ValueError, match=r"GatewayClient\(hedge_after_s"):
_client(hedge_after_s=0)
def test_hedge_guard_empty_sources_raises_with_hedge_location(self):
"""直传路空 sources + 设阈值: ValueError 且消息定位到 hedge(不是裸 min() 报错)。"""
from polygateway.config import check_hedge_assembly
with pytest.raises(ValueError, match="sources") as ei:
check_hedge_assembly(
hedge_after_s=8,
hedge_max_extra=1,
sources=[],
call_deadline_s=None,
origin="GatewayClient(hedge_after_s=8)",
)
assert "hedge_after_s" in str(ei.value) # 定位得到是哪个键
# 未启用对冲(两阈值皆 None)时空 sources 直接放行: 交叉守卫没有可校验的对象;
# 返回契约(1.3.8 审查 B2): tuple(归一化 after_s, 归一化 stall_after_s),
# 两阈值皆 None 才早退
assert check_hedge_assembly(
hedge_after_s=None,
hedge_max_extra=1,
sources=[],
call_deadline_s=None,
origin="test",
) == (None, None)
def test_hedge_guard_below_min_timeout_raises(self):
"""阈值 ≥ 最小源 timeout_s = 对冲永不可能触发,装配期炸掉(ValueError)。"""
env = self._two_source_env(**{"LLM__HEDGE__AFTER_S": "90"}) # min(timeout)=90
with pytest.raises(ValueError, match="timeout_s"):
GatewaySettings.from_env("LLM", env=env)
# 边界内侧合法(89 < 90)
s = GatewaySettings.from_env(
"LLM", env=self._two_source_env(**{"LLM__HEDGE__AFTER_S": "89"})
)
assert s.hedge_after_s == 89.0
def test_hedge_guard_ttft_warns(self):
"""阈值 ≥ 最小已设 ttft_timeout_s: 流式被看门狗先切,装配期 warning 而非 ValueError。"""
env = self._two_source_env(
**{
"LLM__QWEN__1__TTFT_TIMEOUT_S": "30",
"LLM__QWEN__1__INTER_TOKEN_TIMEOUT_S": "15",
"LLM__HEDGE__AFTER_S": "35", # ≥ ttft 30, < timeout 90
}
)
with _captured_warnings() as warnings:
s = GatewaySettings.from_env("LLM", env=env)
assert s.hedge_after_s == 35.0 # warning 不是拒绝: 非流式仍有效
assert any("ttft_timeout_s" in m for m in warnings)
# 阈值低于看门狗时不告警
with _captured_warnings() as warnings2:
GatewaySettings.from_env(
"LLM",
env=self._two_source_env(
**{
"LLM__QWEN__1__TTFT_TIMEOUT_S": "30",
"LLM__QWEN__1__INTER_TOKEN_TIMEOUT_S": "15",
"LLM__HEDGE__AFTER_S": "25",
}
),
)
assert not warnings2
def test_hedge_guard_single_source_warns(self):
"""单源 scope 设阈值: 装配期 warning 放行,运行期拿不到候选自然静默。"""
with _captured_warnings() as warnings:
s = GatewaySettings.from_env("LLM", env=_env(**{"LLM__HEDGE__AFTER_S": "8"}))
assert s.hedge_after_s == 8.0
assert any("单源" in m for m in warnings)
def test_hedge_guard_deadline_conflict_raises(self):
"""阈值 ≥ call_deadline_s: 期限先于对冲触发,对冲形同虚设 → ValueError(§6)。"""
env = self._two_source_env(**{"LLM__HEDGE__AFTER_S": "35", "LLM__CALL_DEADLINE_S": "30"})
with pytest.raises(ValueError, match="call_deadline_s"):
GatewaySettings.from_env("LLM", env=env)
# 边界值(恰好相等)同样拒绝
with pytest.raises(ValueError, match="call_deadline_s"):
GatewaySettings.from_env(
"LLM",
env=self._two_source_env(
**{"LLM__HEDGE__AFTER_S": "30", "LLM__CALL_DEADLINE_S": "30"}
),
)
# 阈值 < 期限是合法组合
s = GatewaySettings.from_env(
"LLM",
env=self._two_source_env(**{"LLM__HEDGE__AFTER_S": "29", "LLM__CALL_DEADLINE_S": "30"}),
)
assert s.hedge_after_s == 29.0 and s.call_deadline_s == 30.0
def test_hedge_max_extra_v1_cap(self):
"""max_extra 值域 [1,3] 的四路校验;>1 已接受但 warning 声明 v1 仅单路生效(H5)。"""
# 域外值无条件拒绝(即使对冲未启用: 非法值没有"惰性"豁免)
with pytest.raises(ValueError, match="hedge_max_extra"):
GatewaySettings.from_env("LLM", env=_env(**{"LLM__HEDGE__MAX_EXTRA": "0"}))
with pytest.raises(ValueError, match="LLM__HEDGE__MAX_EXTRA"):
GatewaySettings.from_env("LLM", env=_env(**{"LLM__HEDGE__MAX_EXTRA": "x"}))
with pytest.raises(ValueError, match="hedge_max_extra"):
GatewaySettings.from_env("LLM", env=_env(**{"LLM__HEDGE__MAX_EXTRA": "4"}))
base = GatewaySettings.from_env("LLM", env=_env())
with pytest.raises(ValueError, match="hedge_max_extra"):
dataclasses.replace(base, hedge_max_extra=0)
# 2/3 接受 + warning: v1 运行期恒单路(对冲任务不再携带首 token 观测,
# 行为面由 test_hedge.py 的 ft_events 断言钉住),梯次追加为 H5 预留
env = self._two_source_env(**{"LLM__HEDGE__AFTER_S": "8", "LLM__HEDGE__MAX_EXTRA": "2"})
with _captured_warnings() as warnings:
s = GatewaySettings.from_env("LLM", env=env)
assert s.hedge_max_extra == 2
assert any("单路" in m for m in warnings)
def test_from_settings_propagates_hedge_to_client(self):
"""from_settings 透传: RetryMW 拿到归一化阈值;max_extra 不下传(v1 无消费者)。"""
env = self._two_source_env(**{"LLM__HEDGE__AFTER_S": "8"})
s = GatewaySettings.from_env("LLM", env=env)
client = GatewayClient.from_settings(s)
assert client._hedge_after_s == 8.0
assert client._terminal._hedge_after_s == 8.0
class TestStallConfig:
"""1.3.8 两新键: `{SCOPE}__STREAM__STALL_THRESHOLD_MS`(计量,默认 1000 三层可调)
与 `{SCOPE}__HEDGE__STALL_AFTER_S`(中段对冲,未设=关闭)及其装配守卫(设计 §6,
计划批次 D;单一定义点仍是 `config.check_hedge_assembly`)。"""
def _two_source_env(self, **overrides):
"""双源 env(同 provider 避免注册表依赖): 隔离单源 warning 的干扰。"""
return _env(
**{
"LLM__QWEN__2__BASE_URL": "https://gw-b.example/v1",
"LLM__QWEN__2__API_KEY": "sk-b",
"LLM__QWEN__2__MODEL": "qwen-plus",
"LLM__QWEN__2__TIMEOUT_S": "90",
**overrides,
}
)
def test_stall_threshold_key_loads_three_layers(self):
"""计量阈值三层调整接口(§9-③ 人类要求): env 键 / settings 字段 / client kwarg。
分发语义: from_settings 恒以 settings 值为权威(覆盖源自带值);client 直传
None = 保留各 SourceConfig 自带值,非 None 经 dataclasses.replace 分发。"""
from tests.unit.test_client import _client
# 层 1: env 键 → settings 字段 → from_settings 分发进每个 SourceConfig
env = self._two_source_env(**{"LLM__STREAM__STALL_THRESHOLD_MS": "2500"})
s = GatewaySettings.from_env("LLM", env=env)
assert s.stall_threshold_ms == 2500
client = GatewayClient.from_settings(s)
assert {src.stall_threshold_ms for src in client._terminal._sources} == {2500}
# 层 2: settings 字段直接构造(dataclasses.replace 同路),settings 值权威:
# 源自带 500 也被 scope 值覆盖(恒分发,计划 §3.5)
base = GatewaySettings.from_env("LLM", env=_env())
custom_source = dataclasses.replace(base.sources[0], stall_threshold_ms=500)
s2 = dataclasses.replace(base, sources=(custom_source,), stall_threshold_ms=800)
client2 = GatewayClient.from_settings(s2)
assert client2._terminal._sources[0].stall_threshold_ms == 800
# 层 3: client kwarg——非 None 分发,None(缺省)保留源自带值
client3 = _client(stall_threshold_ms=600)
assert client3._terminal._sources[0].stall_threshold_ms == 600
client4 = _client(sources=[custom_source])
assert client4._terminal._sources[0].stall_threshold_ms == 500 # 不 stomp
# 未设恒 1000(SourceConfig 缺省)
assert GatewaySettings.from_env("LLM", env=_env()).stall_threshold_ms == 1000
assert _client()._terminal._sources[0].stall_threshold_ms == 1000
def test_stream_segment_reserved(self):
"""`STREAM` 进保留段: 四段 `LLM__STREAM__1__*` 不造源(1.3.7 HEDGE 同款)。"""
env_collision = _env(
**{
"LLM__STREAM__1__BASE_URL": "https://gw-c.example/v1",
"LLM__STREAM__1__API_KEY": "sk-c",
"LLM__STREAM__1__MODEL": "m-c",
"LLM__STREAM__1__TIMEOUT_S": "60",
}
)
s = GatewaySettings.from_env("LLM", env=env_collision)
assert [src.name for src in s.sources] == ["qwen_1"]
# 三段键天然跳过 `_load_sources` 的段数判据,也不造源
env = _env(**{"LLM__STREAM__STALL_THRESHOLD_MS": "2000"})
s2 = GatewaySettings.from_env("LLM", env=env)
assert [src.name for src in s2.sources] == ["qwen_1"]
def test_stall_after_s_guard_effective_minimum_raises(self):
"""`stall_after_s ≥ min(inter_token_timeout_s or timeout_s)` → ValueError。
有效值回落与 openai_compat.py:573 看门狗装配同款: 配 watchdog 的源用
inter_token_timeout_s,未配的源用 timeout_s;对冲窗永不开装配期直接拒绝。"""
# 源 1 配 watchdog(inter_token 15)、源 2 纯 timeout(90): 有效最小值 = 15
env = self._two_source_env(
**{
"LLM__QWEN__1__TTFT_TIMEOUT_S": "30",
"LLM__QWEN__1__INTER_TOKEN_TIMEOUT_S": "15",
"LLM__HEDGE__STALL_AFTER_S": "15", # ≥ 15 → 拒绝(边界含等号)
}
)
with pytest.raises(ValueError, match="STALL_AFTER_S"):
GatewaySettings.from_env("LLM", env=env)
s = GatewaySettings.from_env(
"LLM",
env=self._two_source_env(
**{
"LLM__QWEN__1__TTFT_TIMEOUT_S": "30",
"LLM__QWEN__1__INTER_TOKEN_TIMEOUT_S": "15",
"LLM__HEDGE__STALL_AFTER_S": "14",
}
),
)
assert s.hedge_stall_after_s == 14.0
# 全源未配 watchdog: 有效最小值 = min(timeout_s) = 90
with pytest.raises(ValueError, match="STALL_AFTER_S"):
GatewaySettings.from_env(
"LLM", env=self._two_source_env(**{"LLM__HEDGE__STALL_AFTER_S": "90"})
)
def test_stall_after_s_guard_deadline_conflict_raises(self):
"""`stall_after_s ≥ call_deadline_s` → ValueError(与 after_s 同款,§11-N5)。"""
env = self._two_source_env(
**{"LLM__HEDGE__STALL_AFTER_S": "30", "LLM__CALL_DEADLINE_S": "30"}
)
with pytest.raises(ValueError, match="call_deadline_s"):
GatewaySettings.from_env("LLM", env=env)
s = GatewaySettings.from_env(
"LLM",
env=self._two_source_env(
**{"LLM__HEDGE__STALL_AFTER_S": "29", "LLM__CALL_DEADLINE_S": "30"}
),
)
assert s.hedge_stall_after_s == 29.0 and s.call_deadline_s == 30.0
def test_stall_after_s_single_source_warns(self):
"""单源 scope 设 stall 阈值: 装配期 warning 放行(1.3.7 §5 同款)。"""
with _captured_warnings() as warnings:
s = GatewaySettings.from_env("LLM", env=_env(**{"LLM__HEDGE__STALL_AFTER_S": "8"}))
assert s.hedge_stall_after_s == 8.0
assert any("单源" in m for m in warnings)
def test_embedding_ocr_scope_hedge_keys_warn(self):
"""B4: Embedding/OCR scope 配对冲键(AFTER_S 或 STALL_AFTER_S)→ 装配 warning;
chat scope 无此 warning(per-call stream,装配期无信号,B4 裁决)。"""
# Embedding: env 路(AFTER_S 与 STALL_AFTER_S 各验一次)
embed_env = {
"EMBED__QWEN__1__BASE_URL": "https://gw.example/v1",
"EMBED__QWEN__1__API_KEY": "sk-a",
"EMBED__QWEN__1__MODEL": "text-embedding-v3",
"EMBED__QWEN__1__TIMEOUT_S": "60",
"EMBED__RETRY__MAX_ATTEMPTS": "3",
"EMBED__RETRY__BACKOFF_BASE_S": "1.0",
"EMBED__RETRY__BACKOFF_MAX_S": "10.0",
"EMBED__BREAKER__FAIL_THRESHOLD": "5",
"EMBED__BREAKER__COOLDOWN_S": "60",
"PGW_CACHE_BACKEND": "none",
"PGW_TELEMETRY_BACKEND": "none",
"EMBED__BATCH_SIZE": "64",
}
with _captured_warnings() as warnings:
EmbeddingSettings.from_env("EMBED", env={**embed_env, "EMBED__HEDGE__AFTER_S": "8"})
assert any("仅对 chat scope 生效" in m for m in warnings)
with _captured_warnings() as warnings:
EmbeddingSettings.from_env(
"EMBED", env={**embed_env, "EMBED__HEDGE__STALL_AFTER_S": "8"}
)
assert any("仅对 chat scope 生效" in m for m in warnings)
# Embedding: 直构造路(__post_init__ 那道,非 from_env 专属)
gw = GatewaySettings.from_env("EMBED", env=embed_env)
gw_hedged = dataclasses.replace(gw, hedge_stall_after_s=8.0)
with _captured_warnings() as warnings:
EmbeddingSettings(gateway=gw_hedged, batch_size=64)
assert any("仅对 chat scope 生效" in m for m in warnings)
# OCR: env 路(1.3.7 AFTER_S 同病顺带补齐 + 新 STALL_AFTER_S)
ocr_env = dict(TestOcrSettings._OCR_ENV)
with _captured_warnings() as warnings:
OcrSettings.from_env("OCR", env={**ocr_env, "OCR__HEDGE__AFTER_S": "8"})
assert any("仅对 chat scope 生效" in m for m in warnings)
with _captured_warnings() as warnings:
OcrSettings.from_env("OCR", env={**ocr_env, "OCR__HEDGE__STALL_AFTER_S": "8"})
assert any("仅对 chat scope 生效" in m for m in warnings)
# chat 侧: 同样的对冲键不产生 B4 warning(双源 env 隔离单源 warning 干扰)
with _captured_warnings() as warnings:
GatewaySettings.from_env(
"LLM", env=self._two_source_env(**{"LLM__HEDGE__STALL_AFTER_S": "8"})
)
assert not any("仅对 chat scope 生效" in m for m in warnings)
+152
View File
@@ -0,0 +1,152 @@
"""`deadline.py` 值域校验与五种形态区分测试(计划 §5 批次 A/B)。
用真实事件循环时钟(期限 0.05s、体 0.3s,4-10 倍余量),不标 slow:
被测对象是"哪一种 TimeoutError"的身份判据,注入钟无法覆盖 `asyncio.timeout`。
"""
import asyncio
import time
import pytest
from polygateway.deadline import ensure_call_deadline, with_call_deadline
from polygateway.errors import CallDeadlineExceeded
# —— 批次 A: 值域 ——
def test_ensure_call_deadline_accepts_none_and_positive():
assert ensure_call_deadline(None, "origin") is None
assert ensure_call_deadline(3, "origin") == 3.0
assert ensure_call_deadline(0.5, "origin") == 0.5
@pytest.mark.parametrize(
"bad",
[0, 0.0, -1, -0.5, float("nan"), float("inf"), float("-inf"), "1", True, False, object(), []],
)
def test_ensure_call_deadline_rejects_out_of_range(bad):
with pytest.raises(ValueError) as exc:
ensure_call_deadline(bad, "GatewayClient(call_deadline_s=...)")
assert "GatewayClient(call_deadline_s=...)" in str(exc.value)
def test_ensure_call_deadline_rejects_huge_int_without_leaking_overflow():
"""超出 float 值域的巨大 int 也统一 ValueError,不泄漏 OverflowError。"""
with pytest.raises(ValueError) as exc:
ensure_call_deadline(10**400, "origin")
assert "origin" in str(exc.value)
# —— 批次 B: 五种形态 ——
async def test_deadline_expiry_raises_call_deadline_exceeded():
async def body():
await asyncio.sleep(0.3)
with pytest.raises(CallDeadlineExceeded) as exc:
await with_call_deadline(body(), deadline_s=0.05, scope="llm")
assert exc.value.scope == "llm"
assert exc.value.deadline_s == 0.05
async def test_inner_timeout_before_expiry_propagates_as_is():
async def body():
async with asyncio.timeout(0.01):
await asyncio.sleep(0.3)
with pytest.raises(TimeoutError) as exc:
await with_call_deadline(body(), deadline_s=5.0, scope="llm")
assert not isinstance(exc.value, CallDeadlineExceeded)
async def test_cleanup_timeout_after_expiry_is_not_relabelled():
"""到期后清理路径自抛 TimeoutError → 原样上抛(钉住身份比较,不看 expired())。"""
async def body():
try:
await asyncio.sleep(0.3)
except asyncio.CancelledError:
raise TimeoutError("cleanup") from None
with pytest.raises(TimeoutError) as exc:
await with_call_deadline(body(), deadline_s=0.05, scope="llm")
assert not isinstance(exc.value, CallDeadlineExceeded)
assert str(exc.value) == "cleanup"
async def test_external_cancel_before_expiry_propagates_cancelled():
entered = asyncio.Event()
async def body():
entered.set()
await asyncio.sleep(0.3)
task = asyncio.create_task(with_call_deadline(body(), deadline_s=5.0, scope="llm"))
await entered.wait()
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
async def test_external_cancel_after_expiry_propagates_cancelled():
"""到期已在途、外部又取消 → 仍是 CancelledError(取消优先,不被改标)。"""
started = asyncio.Event()
async def body():
started.set()
try:
await asyncio.sleep(0.3)
except asyncio.CancelledError:
await asyncio.sleep(0.2) # 清理期,期间遭外部取消
raise
task = asyncio.create_task(with_call_deadline(body(), deadline_s=0.05, scope="llm"))
await started.wait()
await asyncio.sleep(0.1) # 让期限先到期,进入清理
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
async def test_narrow_success_returns_value_without_pending_cancellation():
async def body():
await asyncio.sleep(0.01)
return "ok"
async def runner():
return await with_call_deadline(body(), deadline_s=0.2, scope="llm")
task = asyncio.create_task(runner())
assert await task == "ok"
assert task.cancelling() == 0
async def test_domain_error_inside_window_propagates():
"""计时器已触发但取消尚未投递的窗口内,体内先抛领域异常 → 原样上抛,期限静默让位。
忙等超过期限: 计时器回调已在 loop 上触发,但任务不挂起取消就投递不进来,
此刻体内同步抛出的领域异常必须原样逃逸(设计 §5.2 形态四)。
"""
class BoomError(RuntimeError):
pass
async def body():
end = time.monotonic() + 0.1
while time.monotonic() < end:
pass
raise BoomError("boom")
with pytest.raises(BoomError):
await with_call_deadline(body(), deadline_s=0.02, scope="llm")
async def test_none_deadline_takes_the_legacy_path():
async def body():
await asyncio.sleep(0.05)
return "ok"
assert await with_call_deadline(body(), deadline_s=None, scope="llm") == "ok"
+99
View File
@@ -13,6 +13,7 @@ import pytest
from loguru import logger from loguru import logger
from polygateway.errors import ( from polygateway.errors import (
CallDeadlineExceeded,
RequestRejectedError, RequestRejectedError,
ResultInvalidError, ResultInvalidError,
SourceDeadError, SourceDeadError,
@@ -197,6 +198,8 @@ class ScriptedEmbedTransport:
def __init__(self, script): def __init__(self, script):
self.script = list(script) self.script = list(script)
self.calls = [] self.calls = []
# 取消用例的确定性窗口: 进入 hang 分支即置位, 不用 sleep 撞窗口
self.entered = asyncio.Event()
async def embed(self, *, texts, source, call_id): async def embed(self, *, texts, source, call_id):
self.calls.append((source.name, list(texts), call_id)) self.calls.append((source.name, list(texts), call_id))
@@ -204,6 +207,7 @@ class ScriptedEmbedTransport:
if isinstance(action, Exception): if isinstance(action, Exception):
raise action raise action
if action == "hang": if action == "hang":
self.entered.set()
await asyncio.Event().wait() await asyncio.Event().wait()
if action == "ok": if action == "ok":
return _vec_for(texts) return _vec_for(texts)
@@ -315,6 +319,20 @@ class TestEmbedBatching:
assert resp.cost is None assert resp.cost is None
class TestEmbedGenerationMs:
"""裸生成时间按批累加(1.3.7 H8): generation_ms = 各批 transport 耗时之和。"""
async def test_generation_ms_sums_batch_transports(self):
# 0.25s 为二进制可精确表示值: int 截断下非精确值会因浮点误差少 1ms
clock = FakeClock()
transport = _ClockAdvancingEmbedTransport([(0.25, "ok"), (0.25, "ok")], clock)
client, _ = _embed_client([_src()], [], transport=transport, now=clock)
resp = await client.embed(["a", "bb", "ccc", "dddd"])
assert resp.call_stats is not None
assert resp.call_stats.attempts == 2
assert resp.call_stats.generation_ms == 500
class TestEmbedPostProcess: class TestEmbedPostProcess:
async def test_normalize_l2(self): async def test_normalize_l2(self):
raw = EmbeddingTransportResult( raw = EmbeddingTransportResult(
@@ -365,6 +383,20 @@ class TestEmbedGovernance:
await task await task
assert (await limiter.source_stats("e1")).inflight == 0 assert (await limiter.source_stats("e1")).inflight == 0
async def test_cancel_in_flight_keeps_the_reservation(self):
"""S7(与 chat 同口径): transport 在途被取消 → 用量未知 → 保留预扣而非退成 0。"""
transport = ScriptedEmbedTransport(["hang"])
client, limiter = _embed_client(
[_src(max_concurrency=1, tpm=1000, est_tokens=400)], [], transport=transport
)
task = asyncio.create_task(client.embed(["a"]))
await transport.entered.wait()
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
stats = await limiter.source_stats("e1")
assert stats.tpm_used == 400 and stats.inflight == 0
async def test_single_timeout_does_not_exhaust_stall_budget(self): async def test_single_timeout_does_not_exhaust_stall_budget(self):
"""issue #8: 一次耗满 timeout 的尝试不得吃掉 stall 预算而使重试失效。 """issue #8: 一次耗满 timeout 的尝试不得吃掉 stall 预算而使重试失效。
@@ -645,3 +677,70 @@ class TestEmbedLogicalCallStats:
assert resp.call_stats.attempts == 0 assert resp.call_stats.attempts == 0
assert resp.call_stats.logical_call_id # 真实 ID,不是空串 assert resp.call_stats.logical_call_id # 真实 ID,不是空串
assert rec.rows == [] # 零遥测行 assert rec.rows == [] # 零遥测行
class _SlowEmbedTransport:
"""假 embedding transport: 每批真实耗时 `delay` 秒。
"N 批共享一份期限"只能用真实等待来证——`asyncio.timeout` 认的是事件循环
时钟,注入钟推不动它(计划 §5 批次 D)。
"""
def __init__(self, *, delay):
self._delay = delay
self.calls = []
async def embed(self, *, texts, source, call_id):
self.calls.append(list(texts))
await asyncio.sleep(self._delay)
return _vec_for(texts)
class TestEmbedCallDeadline:
"""embedding 的期限语义: 整次调用一份,空输入豁免(计划 §5 批次 D/E)。"""
async def test_one_deadline_is_shared_across_all_batches(self):
"""按批各起一份会让期限被批数放大 N 倍: 单批 0.05s 远小于期限 0.5s 时将永不到期。
余量刷到 10 倍(单批 0.05s vs 期限 0.5s): 要报假结论得单批慢 10 倍,
而不是机器抳一下就变色。
"""
transport = _SlowEmbedTransport(delay=0.05)
client, _ = _embed_client([_src()], [], batch_size=1, transport=transport)
loop = asyncio.get_running_loop()
started = loop.time()
with pytest.raises(CallDeadlineExceeded) as exc:
await client.embed([str(i) for i in range(20)], call_deadline_s=0.5)
elapsed = loop.time() - started
assert exc.value.scope == "embed"
# 按批计的话 20 批全都能跑完(根本不会抛),共享一份则跑不到头
assert 1 <= len(transport.calls) < 20
assert elapsed < 20 * 0.05, f"总时长疑似随批数放大: {elapsed}s"
async def test_empty_input_is_exempt_from_the_deadline(self):
"""`texts == []` 早返回在 try 之外(零尝试、无等待可治),再小的期限也不该拦它。"""
transport = ScriptedEmbedTransport([])
client, _ = _embed_client([_src()], [], transport=transport)
resp = await client.embed([], call_deadline_s=1e-6)
assert resp.vectors == []
assert resp.call_stats is not None and resp.call_stats.attempts == 0
assert transport.calls == []
async def test_the_same_tiny_deadline_does_fire_on_a_non_empty_input(self):
"""对照组: 上一条用的 1e-6 秒确实是会到期的值,豁免不是因为期限没生效。"""
transport = _SlowEmbedTransport(delay=0.05)
client, _ = _embed_client([_src()], [], transport=transport)
with pytest.raises(CallDeadlineExceeded):
await client.embed(["a"], call_deadline_s=1e-6)
async def test_illegal_per_call_value_is_rejected_at_the_entry(self):
"""per-call 非法值当场 ValueError,且消息指向 `embed(...)` 而非某个 env 键。"""
transport = ScriptedEmbedTransport([])
client, _ = _embed_client([_src()], [], transport=transport)
with pytest.raises(ValueError, match=r"embed\(call_deadline_s"):
await client.embed(["a"], call_deadline_s=0)
assert transport.calls == []
def test_illegal_constructor_value_is_rejected_at_assembly(self):
with pytest.raises(ValueError, match=r"EmbeddingClient\(call_deadline_s"):
_embed_client([_src()], [], call_deadline_s=-1)
+645
View File
@@ -0,0 +1,645 @@
"""对冲编排测试(issue #24 设计 §4.6 / 计划批次 G)。
设施纪律(计划 §5): 两源 scope、事件驱动假 transport(每源一对 entered/release
Event + 可脚本化"先置首 token 再挂起")、**真实 loop 钟**(不注入 FakeClock)、
`hedge_after_s=0.05`、断言容差 4–10×;取消窗口用 entered 双 Event 栅栏钉死,
禁 sleep 撞窗口;计时只断言下界与相对比较,不断言精确值。
"""
import asyncio
import time
import pytest
from polygateway import CallDeadlineExceeded
from polygateway.backends.memory.breaker import InMemoryGate
from polygateway.backends.memory.limiter import InMemoryLimiter
from polygateway.errors import AllSourcesExhausted, TransientError
from polygateway.middleware.retry import RetryMW
from polygateway.sources import SourceCooldownMemo
from polygateway.types import (
BackpressurePolicy,
BreakerConfig,
ChatRequest,
GlobalLimits,
RetryPolicy,
_CallContext,
)
from tests.contracts.conftest import FakeClock
from tests.unit.test_retry import RecordingSelector, StaticSelector, _ok, _src
_BREAKER = BreakerConfig(fail_threshold=3, cooldown_s=60.0, probe_ttl_s=120.0)
_NO_GLOBAL = GlobalLimits(max_concurrency=0, rpm=0, tpm=0)
_HEDGE_AFTER_S = 0.05 # 真实 loop 钟阈值;断言上界取 10×(0.5s)
class HedgeTransport:
"""事件驱动假 transport: 按源剧本精确控制首 token 置位与完成时刻。
剧本动作(每源一条队列,耗尽后重复最后一项——429 风暴用例需无限供应):
("hang",) — 置位该源 entered,挂起直到该源 release(或被取消)
("token_then_hang",) — 先置 first_token_event 再 hang(首 token 已至)
("succeed", content) — 立即成功
("succeed_after", delay, content)— 真实 loop 钟睡 delay 后成功
("fail_after", delay, factory) — 睡 delay 后抛 `factory()` 新造的异常
"""
def __init__(self, scripts: dict[str, list[tuple]]):
self._scripts = {name: list(actions) for name, actions in scripts.items()}
self.calls: list[str] = []
# 逐次记录收到的 first_token_event 身份(H5: 对冲路恒为 None,不再梯次)
self.ft_events: list[object] = []
# content_event 同款逐次记录(1.3.8 N3③: 对冲路恒 None 的断言原料)
self.ce_events: list[object] = []
self.entered = {name: asyncio.Event() for name in scripts}
self.release = {name: asyncio.Event() for name in scripts}
async def complete(
self,
*,
messages,
source,
stream,
overlay,
call_id,
reasoning_effort,
first_token_event,
content_event,
response_mode,
):
self.calls.append(source.name)
self.ft_events.append(first_token_event)
self.ce_events.append(content_event)
actions = self._scripts[source.name]
action = actions.pop(0) if len(actions) > 1 else actions[0]
kind = action[0]
if kind == "hang":
self.entered[source.name].set()
await self.release[source.name].wait()
return _ok(f"ok-{source.name}")
if kind == "token_then_hang":
if first_token_event is not None:
first_token_event.set()
self.entered[source.name].set()
await self.release[source.name].wait()
return _ok(f"ok-{source.name}")
if kind == "succeed":
return _ok(action[1])
if kind == "succeed_after":
await asyncio.sleep(action[1])
return _ok(action[2])
if kind == "fail_after":
await asyncio.sleep(action[1])
raise action[2]()
raise AssertionError(f"未知剧本动作: {action!r}")
class BlockingLimiter:
"""限流包装: 挂起指定源的 try_acquire 直到测试放行(确定性复现"对冲准入挂起")。
`acquiring` 置位 = 对冲准入已停在该源闸内;`allow` 置位后才继续。只拦对冲
会走到的源,原路准入不受影响;其余方法逐字委托内层 InMemoryLimiter。
"""
def __init__(self, inner: InMemoryLimiter, block_source: str):
self._inner = inner
self._block_source = block_source
self.acquiring = asyncio.Event()
self.allow = asyncio.Event()
async def try_acquire(self, source_key, est_tokens):
if source_key == self._block_source:
self.acquiring.set()
await self.allow.wait()
return await self._inner.try_acquire(source_key, est_tokens)
async def acquire(self, source_key, est_tokens):
return await self._inner.acquire(source_key, est_tokens)
async def source_stats(self, source_key):
return await self._inner.source_stats(source_key)
async def mark_progress(self):
return await self._inner.mark_progress()
async def progress_age_s(self):
return await self._inner.progress_age_s()
class RecordingEmitter:
"""逐次遥测假 emitter: 记录每行的源/错误标签/逻辑调用 ID/attempt call_id。"""
def __init__(self):
self.rows: list[dict] = []
async def emit_attempt(
self,
*,
request,
source,
call_id,
latency_ms,
response,
error,
reasoning_applies,
operation,
# 1.3.8: `_emit` 显式转发 stall 两值(审查 B1)——显式全签名 fake 不加则 TypeError;
# 默认 None 只护调用方,护不住被调的显式签名
stall_ms=None,
stall_count=None,
):
self.rows.append(
{
"source": source.name,
"call_id": call_id,
"logical_call_id": request.call_context.logical_call_id
if request.call_context is not None
else None,
"error": error,
# 1.3.8: 逐行捕获 stall 归因(取消输家行须恒 None,设计 §11-B2)
"stall_ms": stall_ms,
"stall_count": stall_count,
}
)
def _harness(
sources,
transport,
*,
hedge_after_s=_HEDGE_AFTER_S,
max_attempts=3,
emitter=None,
selector=None,
limiter=None,
gate=None,
stall_window_s=300.0,
now=None,
):
"""真实 loop 钟装配(对冲计时纪律: 只用 loop 相对时长,不注入 FakeClock)。
`now` 仅供"注入钟与对冲触发正交"用例注入 FakeClock——触发路径结构性不读
它,注入只是为了证明这一点。
"""
limiter = limiter or InMemoryLimiter(
scope="llm",
sources={s.name: s for s in sources},
global_limits=_NO_GLOBAL,
lease_ttl_s=100.0,
)
gate = gate or InMemoryGate(config=_BREAKER)
mw = RetryMW(
scope="llm",
sources=sources,
# 固定配置序: 原路恒为 s1、对冲路恒为 s2,断言不依赖选源器内部状态
selector=selector if selector is not None else StaticSelector(),
limiter=limiter,
gate=gate,
transport=transport,
retry=RetryPolicy(max_attempts=max_attempts, backoff_base_s=0.01, backoff_max_s=0.05),
backpressure=BackpressurePolicy(stall_window_s=stall_window_s, poll_interval_s=0.01),
quota_full="wait",
cooldown_memo=SourceCooldownMemo(),
emitter=emitter,
hedge_after_s=hedge_after_s,
**({"now": now} if now is not None else {}),
)
return mw, limiter, gate
def _req(*, stream=False, ctx=None):
return ChatRequest(
messages=[{"role": "user", "content": "hi"}], stream=stream, call_context=ctx
)
def _ctx():
return _CallContext(now=time.monotonic)
class TestHedgeTrigger:
"""触发两形态(验收矩阵 ①): 非流式纯时间阈值;流式以首 token 未至为判据。"""
async def test_non_stream_triggers_hedge_and_fast_leg_wins(self):
"""s1 挂起、s2 即时成功: 对冲截断长尾,赢家为对冲路(⑩: 计时不含触发前等待)。"""
s1, s2 = _src("s1"), _src("s2")
transport = HedgeTransport({"s1": [("hang",)], "s2": [("succeed_after", 0.02, "fast")]})
mw, _, _ = _harness([s1, s2], transport)
ctx = _ctx()
started = time.monotonic()
# wait_for 是防挂安全带(红相位无实现时 5s 判负),不是计时断言
resp = await asyncio.wait_for(mw(_req(stream=False, ctx=ctx)), timeout=5)
elapsed = time.monotonic() - started
assert resp.content == "fast" and resp.source_name == "s2"
assert elapsed < 10 * _HEDGE_AFTER_S # 挂起路被对冲截断,而非等到释放
stats = ctx.snapshot()
assert stats.hedges == 1 and stats.hedge_won is True
# 赢家裸生成时间: 下界 = s2 实际 transport 耗时(0.02s 留截断余量),
# 且严格小于总时长(不含 0.05s 触发窗等待)
assert stats.generation_ms >= 15
assert stats.generation_ms < stats.total_latency_ms
async def test_stream_triggers_only_when_first_token_absent(self):
"""两例(①): 首 token 未至 → 触发;先置首 token 再挂起 → 不触发。"""
# 例一: 流式但首 token 未至,阈值到 → 对冲触发
s1, s2 = _src("s1"), _src("s2")
transport = HedgeTransport({"s1": [("hang",)], "s2": [("succeed", "fast")]})
mw, _, _ = _harness([s1, s2], transport)
ctx = _ctx()
resp = await asyncio.wait_for(mw(_req(stream=True, ctx=ctx)), timeout=5)
assert resp.source_name == "s2"
stats = ctx.snapshot()
assert stats.hedges == 1 and stats.hedge_won is True and stats.attempts == 2
# 例二: 首 token 已至(假 transport 先 set 再挂起)→ 不触发,误杀慢生成即此处
transport2 = HedgeTransport({"s1": [("token_then_hang",)], "s2": [("succeed", "other")]})
mw2, _, _ = _harness([_src("s1"), _src("s2")], transport2)
ctx2 = _ctx()
async def release_later():
await asyncio.sleep(4 * _HEDGE_AFTER_S) # 4× 余量确认窗口已过
transport2.release["s1"].set()
releaser = asyncio.create_task(release_later())
resp2 = await asyncio.wait_for(mw2(_req(stream=True, ctx=ctx2)), timeout=5)
await releaser
assert resp2.source_name == "s1"
assert transport2.calls == ["s1"] # 对冲从未发出
stats2 = ctx2.snapshot()
assert stats2.hedges == 0 and stats2.hedge_won is False and stats2.attempts == 1
async def test_injected_clock_jump_does_not_trigger_hedge(self):
"""对冲触发只认真实 loop 钟: 注入钟跳 10^6 秒不得触发对冲(设计 §8 验收矩阵)。
s1 挂起剧本 + 触发窗内注入钟拨快 10^6 秒: 若触发路径误读注入钟,对冲会
**立即**发出;断言对冲实际发出时刻不早于真实 loop 阈值(下界断言,不断
精确值),形态同 test_client.py:1974 deadline 的注入钟对应用例。
"""
clock = FakeClock()
transport = HedgeTransport({"s1": [("hang",)], "s2": [("succeed", "hedged")]})
mw, _, _ = _harness([_src("s1"), _src("s2")], transport, now=clock)
ctx = _ctx()
task = asyncio.ensure_future(mw(_req(ctx=ctx)))
await transport.entered["s1"].wait() # 原路在途,触发窗计时中
clock.advance(1_000_000.0) # 跳变落在窗内: 误读注入钟即立刻触发
started = time.monotonic()
resp = await asyncio.wait_for(task, timeout=5)
elapsed = time.monotonic() - started
assert resp.source_name == "s2" # 对冲确由真实 loop 阈值触发并截断长尾
assert transport.calls == ["s1", "s2"]
# 下界留 20% 调度余量;误读注入钟的触发是毫秒级,与此差一个数量级以上
assert elapsed >= _HEDGE_AFTER_S * 0.8
stats = ctx.snapshot()
assert stats.hedges == 1 and stats.hedge_won is True
class TestHedgeRouting:
"""异源排除与静默放弃(验收矩阵 ②③)。"""
async def test_hedge_goes_to_other_source(self):
"""对冲请求落在另一源;两 attempt 行共享同一 logical_call_id(②⑤)。"""
emitter = RecordingEmitter()
transport = HedgeTransport({"s1": [("hang",)], "s2": [("succeed", "hedged")]})
mw, _, _ = _harness([_src("s1"), _src("s2")], transport, emitter=emitter)
ctx = _ctx()
resp = await asyncio.wait_for(mw(_req(ctx=ctx)), timeout=5)
assert resp.source_name == "s2"
assert transport.calls == ["s1", "s2"] # 第二请求落在异源
# H5 接缝: 原路携带首 token 观测,对冲路恒 None(v1 单路,不再梯次)
assert [e is not None for e in transport.ft_events] == [True, False]
assert len(emitter.rows) == 2
assert {r["logical_call_id"] for r in emitter.rows} == {ctx.logical_call_id}
assert emitter.rows[0]["call_id"] != emitter.rows[1]["call_id"] # 各 attempt 独立 ID
async def test_hedge_silent_when_no_candidate(self):
"""异源配额被占满 → 准入失败静默放弃: 不对冲、不抛错、原请求照等(③)。"""
s1 = _src("s1")
s2 = _src("s2", max_concurrency=1)
limiter = InMemoryLimiter(
scope="llm", sources={"s1": s1, "s2": s2}, global_limits=_NO_GLOBAL, lease_ttl_s=100.0
)
held = await limiter.try_acquire("s2", 0) # 外部预占满 s2 并发
assert held is not None
try:
transport = HedgeTransport({"s1": [("hang",)], "s2": [("succeed", "x")]})
mw, _, _ = _harness([s1, s2], transport, limiter=limiter)
ctx = _ctx()
task = asyncio.ensure_future(mw(_req(ctx=ctx)))
await transport.entered["s1"].wait()
# 4× 余量: 给对冲窗与那次注定失败的准入留足发生时间
await asyncio.sleep(4 * _HEDGE_AFTER_S)
assert transport.calls == ["s1"] # 对冲静默未发出
transport.release["s1"].set()
resp = await asyncio.wait_for(task, timeout=5)
assert resp.source_name == "s1"
stats = ctx.snapshot()
assert stats.hedges == 0 and stats.attempts == 1 and stats.hedge_won is False
finally:
await held.release()
async def test_pick_exclude_all_is_not_a_rejection(self):
"""exclude 覆盖全源 → 返回 None 且 gate_rejections==0、reasons 不写(排除 ≠ 拒绝)。
admission 级直接钉(admission.py:192 `continue` 语义): 若未来重构把排除计入
gate_rejections,`on_no_runnable` 的"全源熔断类拒绝"判据会被污染,此钉当场报警。
"""
transport = HedgeTransport({"s1": [("succeed", "x")], "s2": [("succeed", "y")]})
mw, _, _ = _harness([_src("s1"), _src("s2")], transport)
reasons = {"prior": "rate_limited"} # 既有原因须原样保留
picked, gate_rejections = await mw._admission.pick(
reasons, {}, exclude=frozenset({"s1", "s2"})
)
assert picked is None
assert gate_rejections == 0
assert reasons == {"prior": "rate_limited"}
async def test_hedge_silent_when_candidate_circuit_open(self):
"""对冲候选被熔断开路 → 静默放弃: 不对冲、不抛错、原请求照等(②③的另一形态)。
现有限流闸用例只钉了"配额占满"一条静默路径;开路/pacer 拒绝走 pick 的另一
分支(gate_rejections 计数、reasons 写 circuit_open、settle_and_release 后
返回 None),同样不得发出对冲请求。
"""
gate = InMemoryGate(config=_BREAKER)
entry = await gate.try_enter("s2", "test-owner")
assert entry.allowed
await gate.record_failure(entry, "source_dead", True) # SourceDead 一击即熔
transport = HedgeTransport({"s1": [("hang",)], "s2": [("succeed", "x")]})
mw, _, _ = _harness([_src("s1"), _src("s2")], transport, gate=gate)
ctx = _ctx()
task = asyncio.ensure_future(mw(_req(ctx=ctx)))
await transport.entered["s1"].wait()
# 4× 余量: 给对冲窗与那次注定被开路拒绝的准入留足发生时间
await asyncio.sleep(4 * _HEDGE_AFTER_S)
assert transport.calls == ["s1"] # 对冲静默未发出
transport.release["s1"].set()
resp = await asyncio.wait_for(task, timeout=5)
assert resp.source_name == "s1"
stats = ctx.snapshot()
assert stats.hedges == 0 and stats.attempts == 1 and stats.hedge_won is False
async def test_primary_done_during_hedge_admission_sends_no_hedge(self):
"""原路在对冲准入 await 期间已完成: 释放对冲准入直接裁定,一个对冲请求都不发。
剧本钉死窗口(禁 sleep 猜): s2 的 try_acquire 挂起(对冲准入停在闸内) →
放行 s1 → 轮询 s1 inflight 归零(_attempt finally 结算完,primary 必 done)
→ 此刻才放行对冲准入。pick 返回时原路已了结,编排必须不落 create_task。
"""
s1 = _src("s1", tpm=1000, est_tokens=400)
s2 = _src("s2", tpm=1000, est_tokens=400)
inner = InMemoryLimiter(
scope="llm",
sources={"s1": s1, "s2": s2},
global_limits=_NO_GLOBAL,
lease_ttl_s=100.0,
)
limiter = BlockingLimiter(inner, "s2")
transport = HedgeTransport({"s1": [("hang",)], "s2": [("succeed", "hedge")]})
mw, _, _ = _harness([s1, s2], transport, limiter=limiter)
ctx = _ctx()
task = asyncio.ensure_future(mw(_req(ctx=ctx)))
await transport.entered["s1"].wait() # 原路在途
await limiter.acquiring.wait() # 对冲准入停在 s2 闸内(触发窗已过)
transport.release["s1"].set() # 原路放行完成
while (await inner.source_stats("s1")).inflight != 0:
await asyncio.sleep(0.001) # 结算完 = primary 已 done(同一任务步内返回)
limiter.allow.set() # pick 此刻才返回: primary.done() 已成立
resp = await asyncio.wait_for(task, timeout=5)
assert resp.content == "ok-s1" and resp.source_name == "s1"
assert transport.calls == ["s1"] # 对冲 HTTP 从未发出(未修前这里会看到 s2)
stats = ctx.snapshot()
assert stats.hedges == 0 and stats.attempts == 1 and stats.hedge_won is False
s2_stats = await inner.source_stats("s2")
assert s2_stats.inflight == 0 and s2_stats.tpm_used == 0 # 对冲准入按 0 结算释放
async def test_hedge_silent_when_single_source(self):
"""单源 scope: 运行期拿不到异源候选自然静默,行为与不配阈值逐字相同(②)。"""
transport = HedgeTransport({"s1": [("hang",)]})
mw, _, _ = _harness([_src("s1")], transport)
ctx = _ctx()
task = asyncio.ensure_future(mw(_req(ctx=ctx)))
await transport.entered["s1"].wait()
await asyncio.sleep(4 * _HEDGE_AFTER_S) # 窗口已过,仍无候选
assert transport.calls == ["s1"]
transport.release["s1"].set()
resp = await asyncio.wait_for(task, timeout=5)
assert resp.content == "ok-s1"
stats = ctx.snapshot()
assert stats.hedges == 0 and stats.attempts == 1 and stats.hedge_won is False
class TestHedgeSettlementAndSignals:
"""赢输记账与熔断/健康信号(验收矩阵 ④⑤;设计 §3 关键判断: 挂起 ≠ 源死亡)。"""
async def test_winner_settles_actual_loser_keeps_est(self):
"""赢家按真实 usage 结算;输家取消落 1.3.6 S3 格: est 预扣保留(④)。"""
s1 = _src("s1", tpm=1000, est_tokens=400)
s2 = _src("s2", tpm=1000, est_tokens=400)
transport = HedgeTransport({"s1": [("hang",)], "s2": [("succeed", "win")]})
mw, limiter, _ = _harness([s1, s2], transport)
resp = await asyncio.wait_for(mw(_req()), timeout=5)
assert resp.source_name == "s2"
winner_stats = await limiter.source_stats("s2")
loser_stats = await limiter.source_stats("s1")
assert winner_stats.tpm_used == 15 # 预扣 400,实测 10+5 → settle 后只记 15
assert loser_stats.tpm_used == 400 # 输家 est 保留(可能被上游计费,保守下限)
assert winner_stats.inflight == 0 and loser_stats.inflight == 0
async def test_loser_row_labelled_hedge_cancelled(self):
"""输家 attempt 行 error=='hedge_cancelled',赢家行无 error,同行逻辑调用(④⑤)。
终态行是 client 级语义且成功调用本就不写终态行(emit_terminal_once 只在
异常/取消路径触发),MW 级可观测面即这两条 attempt 行。
"""
emitter = RecordingEmitter()
transport = HedgeTransport({"s1": [("hang",)], "s2": [("succeed", "win")]})
mw, _, _ = _harness([_src("s1"), _src("s2")], transport, emitter=emitter)
ctx = _ctx()
await asyncio.wait_for(mw(_req(ctx=ctx)), timeout=5)
assert len(emitter.rows) == 2
loser = next(r for r in emitter.rows if r["source"] == "s1")
winner = next(r for r in emitter.rows if r["source"] == "s2")
assert loser["error"] == "hedge_cancelled"
assert winner["error"] is None
assert loser["logical_call_id"] == winner["logical_call_id"] == ctx.logical_call_id
async def test_hedge_cancelled_row_has_null_stall(self):
"""被取消输家无 TransportResult: `hedge_cancelled` 行 stall 两值恒 None(1.3.8 B2)。"""
emitter = RecordingEmitter()
transport = HedgeTransport({"s1": [("hang",)], "s2": [("succeed", "win")]})
mw, _, _ = _harness([_src("s1"), _src("s2")], transport, emitter=emitter)
await asyncio.wait_for(mw(_req()), timeout=5)
loser = next(r for r in emitter.rows if r["error"] == "hedge_cancelled")
assert loser["stall_ms"] is None
assert loser["stall_count"] is None
async def test_loser_does_not_feed_breaker(self):
"""输家取消不喂熔断失败计数、不喂健康分;赢家照常 record_success(④)。"""
selector = RecordingSelector()
gate = InMemoryGate(config=_BREAKER)
transport = HedgeTransport({"s1": [("hang",)], "s2": [("succeed", "win")]})
mw, _, _ = _harness([_src("s1"), _src("s2")], transport, selector=selector, gate=gate)
await asyncio.wait_for(mw(_req()), timeout=5)
gate_s1 = gate._gates["s1"]
assert gate_s1.a0 + gate_s1.a1 == 0 # 熔断失败率窗口无样本
assert selector.outcomes == [("s2", True)] # 健康喂数只有赢家的成功
assert (await gate.try_enter("s1", "w")).allowed # 挂起源未被标记
async def test_attempts_two_and_no_task_leak(self):
"""快照 attempts==2(含输家);返回后无本调用残留任务(⑤)。"""
before = asyncio.all_tasks()
transport = HedgeTransport({"s1": [("hang",)], "s2": [("succeed", "win")]})
mw, _, _ = _harness([_src("s1"), _src("s2")], transport)
ctx = _ctx()
await asyncio.wait_for(mw(_req(ctx=ctx)), timeout=5)
assert ctx.snapshot().attempts == 2
assert asyncio.all_tasks() == before
class TestHedgeCancellation:
"""取消穿透(⑦)与期限组合(⑧): 两任务同消、不 shield、不留后台任务。"""
async def test_external_cancel_cancels_both_legs(self):
"""两路均在途时外部取消: CancelledError 上抛,两 permit 释放。
输家标记只在赢家产生后才置位——外部取消下没有赢家,两行都是普通
"cancelled"(⑦;竞速误贴属设计 §4.5 已批准残留)。
"""
emitter = RecordingEmitter()
s1, s2 = _src("s1"), _src("s2")
transport = HedgeTransport({"s1": [("hang",)], "s2": [("hang",)]})
mw, limiter, _ = _harness([s1, s2], transport, emitter=emitter)
task = asyncio.ensure_future(mw(_req()))
# 双 Event 栅栏: 确认对冲已触发、两路均在途,再取消(禁 sleep 猜窗口)
await transport.entered["s1"].wait()
await transport.entered["s2"].wait()
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert {r["source"]: r["error"] for r in emitter.rows} == {
"s1": "cancelled",
"s2": "cancelled",
}
assert (await limiter.source_stats("s1")).inflight == 0
assert (await limiter.source_stats("s2")).inflight == 0
async def test_deadline_cuts_hedged_tree(self):
"""client 级 call_deadline_s=0.2 + 两路挂起 → CallDeadlineExceeded,permit 全释放(⑧)。"""
from tests.unit.test_client import _client
s1, s2 = _src("s1"), _src("s2")
limiter = InMemoryLimiter(
scope="llm", sources={"s1": s1, "s2": s2}, global_limits=_NO_GLOBAL
)
transport = HedgeTransport({"s1": [("hang",)], "s2": [("hang",)]})
client = _client(
sources=[s1, s2],
transport=transport,
limiter=limiter,
call_deadline_s=0.2,
hedge_after_s=_HEDGE_AFTER_S,
)
async with client:
with pytest.raises(CallDeadlineExceeded):
await client.chat([{"role": "user", "content": "hi"}], stream=False)
assert transport.calls == ["s1", "s2"] # 期限截止前对冲确已触发
assert (await limiter.source_stats("s1")).inflight == 0
assert (await limiter.source_stats("s2")).inflight == 0
class TestHedgeWinnerAdjudication:
"""赢家裁定(⑩): 取快者,含原路后发先至的对称面。"""
async def test_primary_late_success_wins_back(self):
"""s1 挂 0.3s(6× 阈值)后成功、s2 对冲路在途: 原路先完成 → 原路赢。"""
emitter = RecordingEmitter()
transport = HedgeTransport({"s1": [("succeed_after", 0.3, "late")], "s2": [("hang",)]})
mw, _, _ = _harness([_src("s1"), _src("s2")], transport, emitter=emitter)
ctx = _ctx()
resp = await asyncio.wait_for(mw(_req(ctx=ctx)), timeout=5)
assert resp.content == "late" and resp.source_name == "s1"
stats = ctx.snapshot()
assert stats.hedges == 1 and stats.hedge_won is False
# 裸生成时间为原路那次 transport 时长(≈300ms,只断言下界与相对关系)
assert 250 <= stats.generation_ms <= stats.total_latency_ms
loser = next(r for r in emitter.rows if r["source"] == "s2")
assert loser["error"] == "hedge_cancelled" # 在途对冲路被裁为输家
class TestHedgeFailureCombination:
"""两败汇合(H6): 只计一次重试预算;429 分账逐字沿用 attempt 级机制。"""
async def test_both_fail_counts_budget_once(self):
"""两路 Transient: max_attempts=2 时恰进第二轮(两败只计一次),第二轮两败后才耗尽。"""
transport = HedgeTransport(
{
"s1": [("fail_after", 0.1, lambda: TransientError("p", source_name="s1"))],
"s2": [("fail_after", 0.12, lambda: TransientError("h", source_name="s2"))],
}
)
mw, _, _ = _harness([_src("s1"), _src("s2")], transport, max_attempts=2)
ctx = _ctx()
with pytest.raises(AllSourcesExhausted) as ei:
await mw(_req(ctx=ctx))
assert ei.value.reason == "retry_exhausted"
# 若两败计两次预算,第一轮即耗尽,这些调用根本不会发生
assert transport.calls == ["s1", "s2", "s1", "s2"]
# 两败轮次同样登记对冲路数(设计 §4.5: 实际并发发出即计)
assert ctx.snapshot().hedges == 2
async def test_stall_attribution_none_when_no_winner(self):
"""两败轮次无赢家(1.3.8 排除条款): generation 不记、stall 归因恒 None。
对冲已实际发出(hedges 照登),但无赢家路 → 裸生成时间与沉默归因都
无归属;两路各自的沉默归属各自 attempt 遥测行(那是 T3 的事)。
"""
transport = HedgeTransport(
{
"s1": [("fail_after", 0.1, lambda: TransientError("p", source_name="s1"))],
"s2": [("fail_after", 0.12, lambda: TransientError("h", source_name="s2"))],
}
)
mw, _, _ = _harness([_src("s1"), _src("s2")], transport, max_attempts=1)
ctx = _ctx()
with pytest.raises(AllSourcesExhausted):
await asyncio.wait_for(mw(_req(ctx=ctx)), timeout=5)
stats = ctx.snapshot()
assert stats.hedges == 1 and stats.hedge_won is False
assert stats.generation_ms == 0
assert stats.stall_ms is None and stats.stall_count is None
async def test_both_429_refund_no_budget(self):
"""两路皆 429: 免预算且耗时退 stall 账——小 stall 窗下终局 stalled 而非耗尽。"""
def _429():
return TransientError("throttled", status_code=429, retry_after_s=0.01)
transport = HedgeTransport(
{"s1": [("fail_after", 0.1, _429)], "s2": [("fail_after", 0.12, _429)]}
)
mw, _, _ = _harness([_src("s1"), _src("s2")], transport, max_attempts=1, stall_window_s=0.3)
with pytest.raises(AllSourcesExhausted) as ei:
await mw(_req())
# max_attempts=1: 任一路计预算都会当场 retry_exhausted;
# 两 429 免预算 → 循环到 stall 窗口判死
assert ei.value.reason == "stalled"
async def test_mixed_429_and_failure_counts_budget(self):
"""一路 429 一路 Transient → 计一次预算、不退还 stall 账(_combine_failures)。"""
transport = HedgeTransport(
{
"s1": [
(
"fail_after",
0.1,
lambda: TransientError("rl", status_code=429, retry_after_s=0.01),
)
],
"s2": [("fail_after", 0.12, lambda: TransientError("boom", source_name="s2"))],
}
)
mw, _, _ = _harness([_src("s1"), _src("s2")], transport, max_attempts=1, stall_window_s=0.3)
with pytest.raises(AllSourcesExhausted) as ei:
await mw(_req())
assert ei.value.reason == "retry_exhausted"
assert transport.calls == ["s1", "s2"] # 恰一轮两路: 计一次预算即耗尽
+6
View File
@@ -277,6 +277,9 @@ async def _complete(observed, *, call_id="a", stream=False):
overlay={}, overlay={},
call_id=call_id, call_id=call_id,
reasoning_effort=None, reasoning_effort=None,
first_token_event=None,
content_event=None,
response_mode="text",
) )
@@ -1266,6 +1269,9 @@ async def test_structured_first_attempt_requires_exact_initial_messages():
overlay={}, overlay={},
call_id="first", call_id="first",
reasoning_effort=None, reasoning_effort=None,
first_token_event=None,
content_event=None,
response_mode="text",
) )
event = capture.attempts(session_id="first", parent_call_id="parent")[0].http[0] event = capture.attempts(session_id="first", parent_call_id="parent")[0].http[0]
assert not request_is_valid(event) assert not request_is_valid(event)
+590
View File
@@ -0,0 +1,590 @@
"""MonkeyOCR 文档结果包解析测试(#34 设计 §5/§5.1)。
fixtures 全部**合成**: 保留 2026-10-21 实测的结构骨架(para_blocks 的块类型、
table/image 的嵌套 `blocks`、span 字段),一切识别文本替换为 `SYNTH-*` 占位——
第三方文档内容许可不明、敏感数据不入库(零业务假设 + P5),真实 ZIP 不提交。
契约表逐行一条,参数化列明各分支。解析函数是 bytes → 结果的纯函数,失败一律
`ResultInvalidError`,**不得漏出原生异常**(漏出会让 `OcrClient._call` 既不写终态遥测
行也不归还半开探针)。
"""
import io
import json
import random
import struct
import zipfile
import pytest
from polygateway.errors import ResultInvalidError
from polygateway.transports import _monkey_document
from polygateway.transports._monkey_document import parse_document_package
from polygateway.types import OcrDocumentElement
from tests.unit._document_fixtures import (
_BBOX,
_body,
_encrypted_package,
_image_block,
_package,
_page,
_span,
_table_block,
_text_block,
)
def _parse(pages=None, **kwargs):
return parse_document_package(_package(pages, **kwargs))
def _assert_rejected_on_purpose(exc: ResultInvalidError) -> None:
"""拒绝必须出自显式校验,不能是 Python 自己绊倒后被外层边界吸收。
外层边界会把 TypeError/KeyError 也归为 ResultInvalidError,所以只断言异常类型,
「显式校验」与「碰巧在别处失败」无法区分(变异检查证实过: 删掉校验后测试仍绿)。
原因链里出现 TypeError/KeyError 即说明有校验缺口。
"""
assert not isinstance(exc.__cause__, TypeError | KeyError), repr(exc.__cause__)
def _invalid(pages=None, **kwargs):
with pytest.raises(ResultInvalidError) as ei:
_parse(pages, **kwargs)
_assert_rejected_on_purpose(ei.value)
class TestHappyPath:
def test_every_block_type_yields_text_per_contract(self):
pages = [
_page(
[
_text_block("SYNTH-TITLE", type_="title"),
_text_block("SYNTH-BODY"),
_text_block("x^{2}", type_="interline_equation"),
_table_block("<table>SYNTH</table>"),
_image_block(),
]
)
]
elements, page_sizes, markdown = _parse(pages)
assert [(e.type, e.text) for e in elements] == [
("title", "SYNTH-TITLE"),
("text", "SYNTH-BODY"),
("interline_equation", "x^{2}"),
("table", "<table>SYNTH</table>"),
("image", ""),
]
assert all(isinstance(e, OcrDocumentElement) for e in elements)
assert all(e.bbox == _BBOX and e.page_index == 0 for e in elements)
assert page_sizes == [(612.0, 792.0)]
assert markdown == "# synth\n"
def test_reading_order_and_page_index_across_pages(self):
pages = [
_page([_text_block("A")], page_idx=0),
_page([_text_block("B"), _text_block("C")], page_idx=1, page_size=(595.0, 842.0)),
]
elements, page_sizes, _ = _parse(pages)
assert [(e.page_index, e.text) for e in elements] == [(0, "A"), (1, "B"), (1, "C")]
assert page_sizes == [(612.0, 792.0), (595.0, 842.0)]
def test_content_list_and_images_are_ignored(self):
"""内容只来自 middle: content_list 把 `$` 转义成 `\\$`(展示格式),取它会污染 LaTeX。"""
garbage = json.dumps([{"type": "text", "text": "GARBAGE \\$"}]).encode()
elements, _, _ = parse_document_package(
_package(
[_page([_text_block("SYNTH $x$")])],
extra=[("doc_content_list.json", garbage), ("images/x.jpg", b"\xff\xd8")],
)
)
assert [e.text for e in elements] == ["SYNTH $x$"]
def test_discarded_blocks_are_not_in_the_result(self):
pages = [_page([_text_block("KEEP")], discarded_blocks=[_text_block("DROPPED")])]
elements, _, _ = _parse(pages)
assert [e.text for e in elements] == ["KEEP"]
def test_need_drop_pages_are_neither_read_nor_rejected(self):
pages = [_page([_text_block("KEEP")], need_drop=True, drop_reason=["synthetic"])]
elements, _, _ = _parse(pages)
assert [e.text for e in elements] == ["KEEP"]
class TestTextAssembly:
def test_multiple_lines_and_spans_are_joined(self):
"""样本全是 1 行 1 span;多行/多 span 路径未经实测,只由构造 fixture 钉住拼接规则。"""
block = {
"type": "text",
"bbox": list(_BBOX),
"lines": [
{"spans": [_span("A"), _span("B")]},
{"spans": [_span("C")]},
],
}
elements, _, _ = _parse([_page([block])])
assert elements[0].text == "AB\nC"
def test_inline_equation_spans_are_wrapped_in_dollars(self):
"""上游惯例;样本中从未出现该 span 类型,未经实测。"""
block = {
"type": "text",
"bbox": list(_BBOX),
"lines": [{"spans": [_span("see "), _span("a+b", "inline_equation"), _span(".")]}],
}
elements, _, _ = _parse([_page([block])])
assert elements[0].text == "see $a+b$."
def test_empty_lines_list_is_empty_text(self):
block = {"type": "text", "bbox": list(_BBOX), "lines": []}
elements, _, _ = _parse([_page([block])])
assert elements[0].text == ""
@pytest.mark.parametrize(
"mutate",
[
pytest.param(lambda b: b.update(lines="not-a-list"), id="lines-not-list"),
pytest.param(lambda b: b.update(lines=["not-a-dict"]), id="line-not-dict"),
pytest.param(lambda b: b["lines"][0].update(spans="x"), id="spans-not-list"),
pytest.param(lambda b: b["lines"][0].update(spans=["x"]), id="span-not-dict"),
pytest.param(lambda b: b["lines"][0]["spans"][0].pop("content"), id="content-missing"),
pytest.param(lambda b: b["lines"][0]["spans"][0].update(content=7), id="content-int"),
pytest.param(
lambda b: b["lines"][0]["spans"][0].update(content=None), id="content-null"
),
],
)
def test_malformed_text_structure_is_rejected_not_silently_empty(self, mutate):
block = _text_block()
mutate(block)
_invalid([_page([block])])
class TestTableAndImage:
def test_table_falls_back_to_latex_when_no_html(self):
body = _body("table_body", [{"type": "table", "latex": "\\begin{tabular}SYNTH"}])
block = {"type": "table", "bbox": list(_BBOX), "blocks": [body]}
elements, _, _ = _parse([_page([block])])
assert elements[0].text == "\\begin{tabular}SYNTH"
@pytest.mark.parametrize(
"block",
[
pytest.param(
{
"type": "table",
"bbox": list(_BBOX),
"blocks": [_body("table_body", [{"type": "table"}])],
},
id="span-has-neither-html-nor-latex",
),
pytest.param(
{"type": "table", "bbox": list(_BBOX), "blocks": []}, id="no-body-sub-block"
),
pytest.param({"type": "table", "bbox": list(_BBOX)}, id="no-blocks-key"),
],
)
def test_table_without_content_is_rejected_not_silently_empty(self, block):
"""表格无内容与"合法空串"无法在结果里区分,故响亮失败。"""
_invalid([_page([block])])
def test_image_text_is_empty_even_with_path_only_span(self):
elements, _, _ = _parse([_page([_image_block()])])
assert (elements[0].type, elements[0].text) == ("image", "")
def test_non_body_sub_blocks_become_independent_elements_after_their_parent(self):
"""默认部署不产生 caption/footnote 子块(未经实测);出现时不得静默丢弃。"""
caption = _body("table_caption", [_span("SYNTH-CAPTION")], bbox=(1.0, 2.0, 3.0, 4.0))
footnote = _body("table_footnote", [_span("SYNTH-NOTE")], bbox=(5.0, 6.0, 7.0, 8.0))
block = _table_block(extra_subs=[caption])
block["blocks"].append(footnote)
elements, _, _ = _parse([_page([block])])
assert [(e.type, e.text) for e in elements] == [
("table", "<table><tr><td>SYNTH-CELL</td></tr></table>"),
("table_caption", "SYNTH-CAPTION"),
("table_footnote", "SYNTH-NOTE"),
]
assert elements[1].bbox == (1.0, 2.0, 3.0, 4.0)
assert all(e.page_index == 0 for e in elements)
@pytest.mark.parametrize(
("blocks", "reason"),
[
pytest.param("not-a-list", "blocks 无效", id="blocks-not-list"),
pytest.param(["not-a-dict"], "块结构无效", id="sub-block-not-dict"),
pytest.param([{"bbox": list(_BBOX)}], "块缺少 type", id="sub-block-without-type"),
pytest.param([{"type": " ", "bbox": list(_BBOX)}], "块缺少 type", id="blank-type"),
],
)
def test_malformed_nested_blocks_are_rejected_with_their_own_reason(self, blocks, reason):
"""外层边界会把 TypeError 等也归为 ResultInvalid,所以必须断言**原因**,否则"显式校验"
与"碰巧在别处失败"无法区分(变异检查发现仅断言类型时删掉校验测试仍绿)。"""
block = {"type": "table", "bbox": list(_BBOX), "blocks": blocks}
with pytest.raises(ResultInvalidError, match=reason):
_parse([_page([block])])
def test_empty_non_body_sub_block_is_not_emitted(self):
caption = _body("table_caption", [_span("")])
elements, _, _ = _parse([_page([_table_block(extra_subs=[caption])])])
assert [e.type for e in elements] == ["table"]
def test_unknown_type_without_lines_or_blocks_keeps_type_and_empty_text(self):
block = {"type": "synthetic_widget", "bbox": list(_BBOX)}
elements, _, _ = _parse([_page([block])])
assert (elements[0].type, elements[0].text) == ("synthetic_widget", "")
def test_unknown_container_keeps_its_body_text(self):
"""未知容器类型的 `*_body` 子块文字并入父元素,不被静默丢弃。"""
block = {
"type": "synthetic_code",
"bbox": list(_BBOX),
"blocks": [_body("synthetic_code_body", [_span("SYNTH-CODE")])],
}
elements, _, _ = _parse([_page([block])])
assert (elements[0].type, elements[0].text) == ("synthetic_code", "SYNTH-CODE")
class TestPageStructure:
@pytest.mark.parametrize(
"payload",
[
pytest.param({}, id="pdf_info-missing"),
pytest.param({"pdf_info": "x"}, id="pdf_info-not-list"),
pytest.param({"pdf_info": []}, id="zero-pages"),
pytest.param([], id="payload-not-dict"),
],
)
def test_document_level_shape(self, payload):
"""零页被拒: 成功解析的文档至少 1 页(与 layout 路径"elements 空合法"不同)。"""
_invalid(middle=json.dumps(payload).encode())
def test_page_not_a_dict(self):
_invalid(["not-a-dict"])
@pytest.mark.parametrize(
"page_size",
[
pytest.param(None, id="missing"),
pytest.param([612.0], id="one-element"),
pytest.param([612.0, 792.0, 1.0], id="three-elements"),
pytest.param([0, 792.0], id="zero-width"),
pytest.param([612.0, -1], id="negative-height"),
pytest.param([True, 792.0], id="bool"),
pytest.param(["612", 792.0], id="string"),
pytest.param([10**400, 792.0], id="huge-int"),
],
)
def test_page_size_invalid(self, page_size):
page = _page([])
if page_size is None:
del page["page_size"]
else:
page["page_size"] = page_size
_invalid([page])
@pytest.mark.parametrize(
"page_idx",
[
pytest.param("missing", id="missing"),
pytest.param("0", id="string"),
pytest.param(0.0, id="float"),
pytest.param(True, id="bool-true-off-position"),
pytest.param(-1, id="negative"),
pytest.param(3, id="out-of-position"),
],
)
def test_page_idx_must_equal_position(self, page_idx):
"""layout 路径对非法 `page_idx` 回退枚举下标,会让 page_sizes 按 page_index 的索引契约静默错位。"""
page = _page([])
if page_idx == "missing":
del page["page_idx"]
else:
page["page_idx"] = page_idx
_invalid([page])
@pytest.mark.parametrize(
"pages",
[
pytest.param([_page([], page_idx=False)], id="false-equals-0-on-first-page"),
pytest.param(
[_page([], page_idx=0), _page([], page_idx=True)], id="true-equals-1-on-second-page"
),
],
)
def test_bool_page_idx_is_rejected_even_when_it_equals_the_position(self, pages):
"""`False == 0`、`True == 1`: 没有显式的 bool 守卫时它们恰好等于页序而被放行。"""
with pytest.raises(ResultInvalidError, match="page_idx"):
_parse(pages)
def test_second_page_must_not_claim_the_first_index(self):
_invalid([_page([], page_idx=0), _page([], page_idx=0)])
def test_para_blocks_missing_is_a_legal_empty_page(self):
elements, page_sizes, _ = _parse([_page()])
assert elements == [] and page_sizes == [(612.0, 792.0)]
def test_para_blocks_not_a_list_is_rejected(self):
_invalid([_page("x")])
class TestBlockStructure:
@pytest.mark.parametrize(
"block",
[
pytest.param("not-a-dict", id="block-not-dict"),
pytest.param({"bbox": list(_BBOX)}, id="type-missing"),
pytest.param({"type": 7, "bbox": list(_BBOX)}, id="type-not-str"),
pytest.param({"type": "", "bbox": list(_BBOX)}, id="type-empty"),
pytest.param({"type": " ", "bbox": list(_BBOX)}, id="type-whitespace"),
],
)
def test_block_shape(self, block):
_invalid([_page([block])])
@pytest.mark.parametrize(
"bbox",
[
pytest.param(None, id="missing"),
pytest.param("1,2,3,4", id="string"),
pytest.param([1, 2, 3], id="length-3"),
pytest.param([1, 2, 3, 4, 5], id="length-5"),
pytest.param([1, 2, "3", 4], id="string-element"),
pytest.param([1, 2, True, 4], id="bool-element"),
pytest.param([1, 2, float("nan"), 4], id="nan"),
pytest.param([1, 2, float("inf"), 4], id="inf"),
pytest.param([10, 2, 5, 4], id="x-reversed"),
pytest.param([1, 20, 5, 4], id="y-reversed"),
pytest.param([0, 0, 10**400, 1], id="huge-int"),
],
)
def test_bbox_invalid(self, bbox):
block = _text_block()
if bbox is None:
del block["bbox"]
else:
block["bbox"] = bbox
# NaN/Infinity 不是合法 JSON,但 Python 的 json 模块默认接受并往返——真实服务可能发出
_invalid([_page([block])])
@pytest.mark.parametrize("bbox", [(5, 5, 5, 20), (5, 5, 50, 5), (5, 5, 5, 5)])
def test_zero_width_or_height_boxes_are_legal(self, bbox):
"""整数化退化校验专为 CHS shim 的 int() 裁剪;文档消费者不裁剪,一个退化框不该让整份失败。"""
elements, _, _ = _parse([_page([_text_block(bbox=bbox)])])
assert elements[0].bbox == tuple(float(v) for v in bbox)
def test_out_of_page_boxes_are_returned_as_is(self):
"""越界与否是图内语义,归调用方(ARCHITECTURE §5.1 边界);库不裁剪。"""
elements, _, _ = _parse([_page([_text_block(bbox=(0, 0, 9999, 9999))])])
assert elements[0].bbox == (0.0, 0.0, 9999.0, 9999.0)
class TestMembers:
def test_missing_middle_json(self):
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("doc.md", "x")
with pytest.raises(ResultInvalidError):
parse_document_package(buf.getvalue())
def test_missing_paired_markdown(self):
_invalid(md=None)
def test_markdown_is_paired_by_name_not_first_match(self):
elements, _, markdown = parse_document_package(
_package(
[_page([_text_block("A")])],
md="PAIRED",
extra=[("aaa_other.md", "DISTRACTOR")],
)
)
assert markdown == "PAIRED"
def test_unpaired_markdown_alone_is_not_accepted(self):
_invalid(md=None, extra=[("other.md", "DISTRACTOR")])
def test_first_middle_json_wins_and_pairs_with_its_own_markdown(self):
pages_a = json.dumps({"pdf_info": [_page([_text_block("A")])]}).encode()
pages_b = json.dumps({"pdf_info": [_page([_text_block("B")])]}).encode()
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("a_middle.json", pages_a)
zf.writestr("b_middle.json", pages_b)
zf.writestr("a.md", "MD-A")
zf.writestr("b.md", "MD-B")
elements, _, markdown = parse_document_package(buf.getvalue())
assert [e.text for e in elements] == ["A"] and markdown == "MD-A"
def test_markdown_bom_is_stripped(self):
_, _, markdown = _parse(md=b"\xef\xbb\xbf# synth")
assert markdown == "# synth"
def test_empty_markdown_is_legal(self):
_, _, markdown = _parse(md="")
assert markdown == ""
def test_non_utf8_markdown_is_rejected(self):
_invalid(md=b"\xff\xfe\x00bad")
@pytest.mark.parametrize(
"middle",
[
pytest.param(b"not json at all", id="not-json"),
pytest.param(b"", id="empty"),
pytest.param(b"\xff\xfe", id="not-utf8"),
],
)
def test_middle_json_unparseable(self, middle):
_invalid(middle=middle)
def test_not_a_zip(self):
with pytest.raises(ResultInvalidError):
parse_document_package(b"definitely not a zip")
def _patch_first_member_compression(zip_bytes: bytes, method: int) -> bytes:
"""把第一个成员的压缩方法号改成 `method`(本地头偏移 8,中央目录偏移 10)。"""
data = bytearray(zip_bytes)
local = data.index(b"PK\x03\x04")
central = data.index(b"PK\x01\x02")
struct.pack_into("<H", data, local + 8, method)
struct.pack_into("<H", data, central + 10, method)
return bytes(data)
class TestExternalDataExceptionBoundary:
"""外部字节引起的一切已知异常都归 `ResultInvalidError`,绝不漏出原生异常。
漏出的后果(`OcrClient._call` 只接 PolyGatewayError): 不写终态遥测行,持有的半开探针
也不归还。边界必须同时盖住「读取」与「全部结构解析」两段。
"""
def test_encrypted_member(self):
with pytest.raises(ResultInvalidError):
parse_document_package(_encrypted_package())
def test_unknown_compression_method(self):
patched = _patch_first_member_compression(_package([_page([])]), 99)
with pytest.raises(ResultInvalidError):
parse_document_package(patched)
def test_crc_corruption(self):
corrupted = _package([_page([])]).replace(b'"pdf_info"', b'"pdf_infq"', 1)
with pytest.raises(ResultInvalidError):
parse_document_package(corrupted)
def test_huge_integer_inside_page_structure_is_not_an_overflow_error(self):
"""`float(10**400)` 抛 OverflowError——发生在结构解析段,不在读取段。"""
block = _text_block()
block["bbox"] = [0, 0, 10**400, 1]
_invalid([_page([block])])
def test_deeply_nested_json_is_not_a_recursion_error(self):
deep = b'{"pdf_info": ' + b"[" * 100_000 + b"]" * 100_000 + b"}"
_invalid(middle=deep)
@pytest.mark.parametrize(
("cap_name", "member"),
[("MAX_MIDDLE_BYTES", "doc_middle.json"), ("MAX_MARKDOWN_BYTES", "doc.md")],
)
def test_declared_size_over_cap_names_the_offending_member(self, cap_name, member, monkeypatch):
"""断言消息里的成员名: 两个上限互换时,只断言异常类型的旧用例仍会通过。"""
monkeypatch.setattr(_monkey_document, cap_name, 8)
with pytest.raises(ResultInvalidError, match=member):
_parse([_page([_text_block("A")])], md="# a markdown longer than eight bytes")
def test_existing_result_invalid_messages_pass_through_unchanged(self):
with pytest.raises(ResultInvalidError, match="至少应有 1 页"):
_parse([])
class TestLayoutPathUntouched:
"""文档路径更严/更宽的口径不得外溢到既有调用方在用的 layout 解析。"""
def test_layout_parser_still_rejects_degenerate_int_boxes(self):
from polygateway.transports.monkey_ocr import _parse_middle_json
block = {"type": "text", "bbox": [5, 5, 5.4, 20], "lines": []}
zip_bytes = _package([_page([block])])
with pytest.raises(ResultInvalidError):
_parse_middle_json(zip_bytes)
def test_layout_parser_keeps_its_lenient_page_idx_handling(self):
"""layout 路径: 合法的非负整数 `page_idx` 原样接受(哪怕与页序不符),非法值才回退
枚举下标——这正是文档路径改为严格的原因,但既有调用方的行为不得被连带改动。"""
from polygateway.transports.monkey_ocr import _parse_middle_json
accepted, _ = _parse_middle_json(_package([_page([_text_block()], page_idx=7)]))
assert accepted[0].page_index == 7
fallback, _ = _parse_middle_json(_package([_page([_text_block()], page_idx="x")]))
assert fallback[0].page_index == 0
class TestZipLayerNeverLeaksNativeExceptions:
"""独立验证发现: BZIP2/LZMA 成员数据损坏会漏出 `OSError`/`LZMAError`,中央目录版本字段损坏会在
`ZipFile` 构造阶段漏出 `NotImplementedError`——它们都不在外层边界里。漏出的后果与设计 §5 一致:
`OcrClient._call` 只接 `PolyGatewayError`,既不写终态遥测行,也不归还半开探针。"""
@staticmethod
def _zip(method: int) -> bytes:
payload = json.dumps({"pdf_info": [_page([_text_block("SYNTH")])]}).encode() * 20
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", compression=method) as zf:
zf.writestr("doc_middle.json", payload)
zf.writestr("doc.md", "# synth " * 50)
return buf.getvalue()
@pytest.mark.parametrize(
"method",
[
pytest.param(zipfile.ZIP_DEFLATED, id="deflated"),
pytest.param(zipfile.ZIP_BZIP2, id="bzip2"),
pytest.param(zipfile.ZIP_LZMA, id="lzma"),
],
)
def test_single_bit_flips_never_escape_as_native_exceptions(self, method):
"""确定性字节翻转(固定种子): 每个结果只能是成功或 `ResultInvalidError`。"""
base = self._zip(method)
rng = random.Random(34)
for _ in range(600):
data = bytearray(base)
position = rng.randrange(len(data))
data[position] ^= 1 << rng.randrange(8)
try:
parse_document_package(bytes(data))
except ResultInvalidError:
continue
except Exception as exc: # noqa: BLE001 — 测试要抓的正是任何漏出的原生异常
pytest.fail(
f"字节 {position} 翻转后漏出 {type(exc).__module__}.{type(exc).__name__}: {exc}"
)
@pytest.mark.parametrize("method", [zipfile.ZIP_BZIP2, zipfile.ZIP_LZMA])
def test_corrupt_non_deflate_member_data(self, method):
"""压缩流内部损坏(而非 CRC 失败): 标准库抛 OSError / LZMAError。"""
data = bytearray(self._zip(method))
local = data.index(b"PK\x03\x04")
name_len, extra_len = struct.unpack_from("<HH", data, local + 26)
body = local + 30 + name_len + extra_len
for offset in range(body, body + 12):
data[offset] ^= 0xFF
with pytest.raises(ResultInvalidError):
parse_document_package(bytes(data))
def test_zipfile_construction_stage_is_inside_the_boundary(self):
"""中央目录的 extract_version 损坏: `ZipFile(...)` 构造阶段抛 NotImplementedError。"""
data = bytearray(self._zip(zipfile.ZIP_STORED))
central = data.index(b"PK\x01\x02")
data[central + 6] = 99 # extract_version = 9.9 → "zip file version 9.9"
with pytest.raises(ResultInvalidError):
parse_document_package(bytes(data))
class TestTextBearingBlocksRequireLines:
"""text/title/interline_equation 缺 `lines` 不是「空文本」而是畸形: 与 table 缺内容就报错同口径,
否则下游分不清「服务识别出空块」和「服务给了坏结构」。"""
@pytest.mark.parametrize("block_type", ["text", "title", "interline_equation"])
def test_missing_lines_is_rejected_with_its_reason(self, block_type):
block = {"type": block_type, "bbox": list(_BBOX)}
with pytest.raises(ResultInvalidError, match="缺少 lines"):
_parse([_page([block])])
+518 -1
View File
@@ -8,6 +8,7 @@ P5 医疗数据不入库)。
import asyncio import asyncio
import io import io
import json import json
import threading
import zipfile import zipfile
import httpx import httpx
@@ -19,12 +20,20 @@ from polygateway.errors import (
SourceDeadError, SourceDeadError,
TransientError, TransientError,
) )
from polygateway.transports import monkey_ocr
from polygateway.transports.monkey_ocr import ( from polygateway.transports.monkey_ocr import (
_DEFAULT_REJECTED_MARKERS,
MonkeyOcrTransport, MonkeyOcrTransport,
_classify_status, _classify_status,
_parse_middle_json, _parse_middle_json,
_sniff_media,
_translate_http_errors,
_upload_part,
) )
from polygateway.types import SourceConfig from polygateway.types import OcrDocumentElement, OcrDocumentTransportResult, SourceConfig
from tests.unit._document_fixtures import _package as _doc_package
from tests.unit._document_fixtures import _page as _doc_page
from tests.unit._document_fixtures import _table_block, _text_block
def _source(**overrides): def _source(**overrides):
@@ -167,6 +176,96 @@ class TestRecognizeText:
assert b'filename="image.jpg"' in seen["body"] # CHS invokers.py:496 固定名 assert b'filename="image.jpg"' in seen["body"] # CHS invokers.py:496 固定名
_PDF_BODY = b"%PDF-1.4\n%synthetic body"
# BMP: "BM" + 文件大小(4) + 保留(4) + 像素偏移(4) + DIB 头大小(4,常见 12/40/52/56/64/108/124)
_BMP_HEADER = b"BM" + b"\x00" * 12 + (40).to_bytes(4, "little") + b"\x00" * 20
_JUNK = b"x" * 200
class TestSniffMedia:
"""上传类型按内容判定(#34 设计 §3 Q2): 图片魔数优先 → 前 1KB 内 `%PDF-` → 未知。"""
@pytest.mark.parametrize(
("data", "expected"),
[
pytest.param(_PDF_BODY, "pdf", id="pdf"),
pytest.param(_JUNK + _PDF_BODY, "pdf", id="pdf-with-junk-prefix"),
pytest.param(b"\xff\xd8\xff\xe0" + b"0" * 20, "image", id="jpeg"),
pytest.param(b"\x89PNG\r\n\x1a\n" + b"0" * 20, "image", id="png"),
pytest.param(b"II*\x00" + b"0" * 20, "image", id="tiff-le"),
pytest.param(b"MM\x00*" + b"0" * 20, "image", id="tiff-be"),
pytest.param(_BMP_HEADER, "image", id="bmp"),
pytest.param(
b"BMW is a car maker, not a bitmap", "unknown", id="text-starting-with-BM"
),
pytest.param(b"BM" + b"\x00" * 40, "unknown", id="BM-with-bad-dib-header"),
pytest.param(b"GIF89a" + b"0" * 20, "image", id="gif89a"),
pytest.param(b"GIF87a" + b"0" * 20, "image", id="gif87a"),
pytest.param(b"RIFF\x00\x00\x00\x00WEBPVP8 ", "image", id="webp"),
# 图片魔数优先: 含 XMP 元数据的图片可能在 1KB 内带 `%PDF-` 字样
pytest.param(
b"\xff\xd8\xff\xe0" + b"<xmp>%PDF-</xmp>", "image", id="jpeg-mentions-pdf"
),
pytest.param(b"<html><body>paywall</body></html>", "unknown", id="html"),
pytest.param(b"GARBAGE-NOT-AN-IMAGE", "unknown", id="garbage"),
pytest.param(b"", "unknown", id="empty"),
# 1KB 窗口之外的 `%PDF-` 不算
pytest.param(b"x" * 1100 + b"%PDF-", "unknown", id="header-beyond-window"),
],
)
def test_sniff(self, data, expected):
assert _sniff_media(data) == expected
def test_upload_part_labels_pdf_only(self):
assert _upload_part(_PDF_BODY) == ("doc.pdf", _PDF_BODY, "application/pdf")
assert _upload_part(_JUNK + _PDF_BODY)[0] == "doc.pdf"
@pytest.mark.parametrize(
"data", [b"\xff\xd8\xff\xe0abc", b"\x89PNG\r\n\x1a\nabc", b"junk", b""]
)
def test_everything_else_keeps_the_legacy_label(self, data):
"""非 PDF 路径字节级不变: 服务对图片按内容解码(2026-10-21 实测 PNG/TIFF/BMP 标 image.jpg 成功)。"""
assert _upload_part(data) == ("image.jpg", data, "image/jpeg")
class TestUploadLabelPerMethod:
"""三个调用点共用同一个标签点: 漏掉任何一个,E4 的静默错误结果就在那里复活。"""
@staticmethod
def _capturing_transport(seen):
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path == "/ocr/text":
seen["body"] = request.read()
return httpx.Response(200, json=_text_body())
if request.url.path == "/parse":
seen["body"] = request.read()
return httpx.Response(200, json=_parse_body())
return httpx.Response(200, content=_zip_bytes())
return _transport_for(handler)
async def test_recognize_text_labels_pdf_as_pdf(self):
seen = {}
t = self._capturing_transport(seen)
await t.recognize_text(image=_PDF_BODY, source=_source(), call_id="c1")
assert b'filename="doc.pdf"' in seen["body"]
assert b"Content-Type: application/pdf" in seen["body"]
async def test_parse_layout_labels_pdf_as_pdf(self):
seen = {}
t = self._capturing_transport(seen)
await t.parse_layout(image=_PDF_BODY, source=_source(), call_id="c1")
assert b'filename="doc.pdf"' in seen["body"]
assert b"Content-Type: application/pdf" in seen["body"]
async def test_parse_layout_keeps_image_label_for_images(self):
seen = {}
t = self._capturing_transport(seen)
await t.parse_layout(image=b"\xff\xd8\xff\xe0abc", source=_source(), call_id="c1")
assert b'filename="image.jpg"' in seen["body"]
assert b"Content-Type: image/jpeg" in seen["body"]
class TestParseLayout: class TestParseLayout:
async def test_full_chain_relative_url(self): async def test_full_chain_relative_url(self):
t = _transport_for( t = _transport_for(
@@ -369,6 +468,424 @@ class TestErrorTranslation:
assert isinstance(ei.value.__cause__, httpx.TimeoutException) assert isinstance(ei.value.__cause__, httpx.TimeoutException)
def _http_error(status, body):
request = httpx.Request("POST", "http://ocr.example/parse")
return httpx.HTTPStatusError(
str(status),
request=request,
response=httpx.Response(status, content=body.encode(), request=request),
)
def _parsing_failed(reason):
# 真实形态(2026-10-21 实测): 服务把一切异常包成 500 + {"detail": "Parsing failed: …"}
return json.dumps({"detail": f"Parsing failed: {reason}"})
class TestRejectedMarkers:
"""#34 设计 §6: 确定性坏输入的 500 在文档路径归「请求被拒」,其余分类一概不动。
共享的 `_classify_status` 默认行为字节级不变——既有调用方对坏图的重试/熔断行为属公共行为,
推广须另行设计(issue #35)。
"""
@pytest.mark.parametrize("marker", _DEFAULT_REJECTED_MARKERS)
def test_known_deterministic_failures_become_rejected_with_status_500(self, marker):
exc = _http_error(500, _parsing_failed(marker.capitalize()))
err = _classify_status(exc, "monkey_1", "parse", _DEFAULT_REJECTED_MARKERS)
assert isinstance(err, RequestRejectedError)
assert err.status_code == 500 # 服务有响应: 熔断按「响应即健康」记成功
assert marker.capitalize() in err.body_text # 响应体摘要仍随该分支
def test_marker_matching_is_case_insensitive(self):
exc = _http_error(500, '{"detail": "PARSING FAILED: FAILED TO OPEN STREAM"}')
err = _classify_status(exc, "monkey_1", "parse", _DEFAULT_REJECTED_MARKERS)
assert isinstance(err, RequestRejectedError)
@pytest.mark.parametrize(
"body",
[
pytest.param('{"detail": "Failed to open stream"}', id="no-parsing-failed-prefix"),
pytest.param(
_parsing_failed("500: Model not initialized"), id="real-fault-same-prefix"
),
pytest.param(
_parsing_failed("400: Unsupported file type: .webp"), id="unsupported-type"
),
pytest.param(_parsing_failed("CUDA out of memory"), id="oom"),
pytest.param("", id="empty-body"),
],
)
def test_everything_else_stays_transient(self, body):
"""`Parsing failed:` 前缀不能单独当判据: 服务把真故障也包成同一前缀。
`Unsupported file type` 有意不在白名单——嗅探后它只可能是契约变化或我方标签 bug。"""
err = _classify_status(
_http_error(500, body), "monkey_1", "parse", _DEFAULT_REJECTED_MARKERS
)
assert isinstance(err, TransientError)
@pytest.mark.parametrize("body", [_parsing_failed(m) for m in _DEFAULT_REJECTED_MARKERS])
def test_default_call_without_markers_is_unchanged(self, body):
err = _classify_status(_http_error(500, body), "monkey_1", "parse")
assert isinstance(err, TransientError)
@pytest.mark.parametrize(
("status", "exc_type"),
[
(429, TransientError),
(502, TransientError),
(503, TransientError),
(401, SourceDeadError),
(403, SourceDeadError),
(404, RequestRejectedError),
],
)
def test_other_statuses_are_never_narrowed(self, status, exc_type):
body = _parsing_failed(_DEFAULT_REJECTED_MARKERS[0])
err = _classify_status(_http_error(status, body), "m", "parse", _DEFAULT_REJECTED_MARKERS)
assert type(err) is exc_type
def test_translate_http_errors_passes_markers_through(self):
with (
pytest.raises(RequestRejectedError),
_translate_http_errors("monkey_1", "parse", rejected_markers=_DEFAULT_REJECTED_MARKERS),
):
raise _http_error(500, _parsing_failed(_DEFAULT_REJECTED_MARKERS[0]))
# 默认不传: 行为不变
with pytest.raises(TransientError), _translate_http_errors("monkey_1", "parse"):
raise _http_error(500, _parsing_failed(_DEFAULT_REJECTED_MARKERS[0]))
def test_markers_are_configurable_per_transport(self):
assert MonkeyOcrTransport()._rejected_markers == _DEFAULT_REJECTED_MARKERS
custom = ("synthetic marker",)
assert MonkeyOcrTransport(deterministic_failure_markers=custom)._rejected_markers == custom
async def test_layout_and_text_paths_do_not_apply_the_markers(self):
"""传输实例持有标记,但只有文档路径使用;既有的两个方法行为不变。"""
body = _parsing_failed(_DEFAULT_REJECTED_MARKERS[0]).encode()
t = _transport_for(
_routes(
text_resp=httpx.Response(500, content=body),
parse_resp=httpx.Response(500, content=body),
)
)
with pytest.raises(TransientError):
await t.recognize_text(image=b"jpg", source=_source(), call_id="c1")
with pytest.raises(TransientError):
await t.parse_layout(image=b"jpg", source=_source(), call_id="c1")
class TestMalformedDownloadUrl:
"""`download_url` 来自服务端响应,属外部输入。畸形值(控制字符、坏方括号、越界端口)会让 httpx
抛 `InvalidURL`(不属 `HTTPError` 体系)或在连接阶段抛 ExceptionGroup,从 `_translate_http_errors`
下漏出四分类——`OcrClient._call` 只接 `PolyGatewayError`,于是不写终态遥测行、半开探针不归还。
(独立审查发现;`parse_layout` 的基线同样如此,共用的 `_post_parse` 一处修。)"""
_BAD_URLS = [
pytest.param("http://[::1", id="broken-bracket"),
pytest.param("/static/a\r\nb.zip", id="crlf"),
pytest.param("/static/a\x00.zip", id="nul"),
pytest.param("/static/a\tb.zip", id="tab"),
pytest.param("http://127.0.0.1:99999/x", id="port-out-of-range"),
]
@staticmethod
def _transport_with(download_url, seen):
def handler(request: httpx.Request) -> httpx.Response:
seen.append((request.method, request.url.path))
if request.url.path == "/parse":
return httpx.Response(200, json=_parse_body(download_url))
return httpx.Response(200, content=b"never fetched")
return _transport_for(handler)
@pytest.mark.parametrize("download_url", _BAD_URLS)
@pytest.mark.parametrize("method", ["parse_layout", "parse_document"])
async def test_rejected_as_transient_without_fetching(self, download_url, method):
seen = []
t = self._transport_with(download_url, seen)
kwargs = {"image": b"jpg"} if method == "parse_layout" else {"document": _PDF_BODY}
with pytest.raises(TransientError, match="download_url"):
await getattr(t, method)(**kwargs, source=_source(), call_id="c1")
assert seen == [("POST", "/parse")] # 从未发出第二段请求
async def test_well_formed_urls_are_untouched(self):
for url in ("/static/ok.zip", "http://10.77.0.20:7866/static/abs.zip"):
seen = []
t = self._transport_with(url, seen)
with pytest.raises(ResultInvalidError): # 响应体不是 ZIP: 说明确实走到了第二段
await t.parse_layout(image=b"jpg", source=_source(), call_id="c1")
assert [m for m, _ in seen] == ["POST", "GET"]
class TestParseDocument:
"""#34 设计 §5: 本地预检 → POST /parse → 流式 GET(体积上限)→ 线程内解析。"""
@staticmethod
def _doc_zip():
return _doc_package(
[_doc_page([_text_block("SYNTH-A"), _table_block("<table>SYNTH</table>")])]
)
def _transport(self, seen=None, *, zip_resp=None, parse_resp=None):
def handler(request: httpx.Request) -> httpx.Response:
if seen is not None:
seen.append((request.method, request.url.path, request.read()))
if request.url.path == "/parse":
return parse_resp or httpx.Response(200, json=_parse_body())
if request.url.path.startswith("/static/"):
return zip_resp or httpx.Response(200, content=self._doc_zip())
return httpx.Response(500)
return _transport_for(handler)
async def test_full_chain_relative_download_url(self):
t = self._transport()
result = await t.parse_document(document=_PDF_BODY, source=_source(), call_id="c1")
assert isinstance(result, OcrDocumentTransportResult)
assert [(e.type, e.text) for e in result.elements] == [
("text", "SYNTH-A"),
("table", "<table>SYNTH</table>"),
]
assert all(isinstance(e, OcrDocumentElement) for e in result.elements)
assert result.page_sizes == [(612.0, 792.0)]
assert result.markdown == "# synth\n"
assert result.raw == {"success": True, "download_url": "/static/sample_parsed_1.zip"}
async def test_absolute_download_url(self):
url = "http://10.77.0.20:7866/static/abs.zip"
t = self._transport(parse_resp=httpx.Response(200, json=_parse_body(url)))
result = await t.parse_document(document=_PDF_BODY, source=_source(), call_id="c1")
assert result.raw["download_url"] == url
@pytest.mark.parametrize(
("document", "filename", "content_type"),
[
pytest.param(_PDF_BODY, b"doc.pdf", b"application/pdf", id="pdf"),
pytest.param(_JUNK + _PDF_BODY, b"doc.pdf", b"application/pdf", id="pdf-junk-prefix"),
pytest.param(b"\xff\xd8\xff\xe0abc", b"image.jpg", b"image/jpeg", id="jpeg"),
],
)
async def test_upload_label_matrix_for_the_document_entry(
self, document, filename, content_type
):
seen = []
await self._transport(seen).parse_document(
document=document, source=_source(), call_id="c1"
)
post_body = next(body for method, path, body in seen if path == "/parse")
assert b'filename="' + filename + b'"' in post_body
assert b"Content-Type: " + content_type in post_body
@pytest.mark.parametrize(
"document",
[b"<html>paywall</html>", b"GARBAGE-NOT-AN-IMAGE", b""],
ids=["html", "garbage", "empty"],
)
async def test_unrecognizable_bytes_are_rejected_locally_without_any_request(self, document):
seen = []
with pytest.raises(RequestRejectedError) as ei:
await self._transport(seen).parse_document(
document=document, source=_source(), call_id="c1"
)
assert seen == [] # 服务从未被打扰
assert ei.value.status_code is None # 服务未响应: 熔断侧只归还探针,不计成败
@pytest.mark.parametrize("marker", _DEFAULT_REJECTED_MARKERS)
async def test_known_deterministic_post_failures_are_rejected_with_status_500(self, marker):
body = _parsing_failed(marker).encode()
t = self._transport(parse_resp=httpx.Response(500, content=body))
with pytest.raises(RequestRejectedError) as ei:
await t.parse_document(document=_PDF_BODY, source=_source(), call_id="c1")
assert ei.value.status_code == 500 and ei.value.body_text
@pytest.mark.parametrize(
"reason",
["CUDA out of memory", "400: Unsupported file type: .webp", "500: Model not initialized"],
)
async def test_unknown_post_500_stays_transient(self, reason):
t = self._transport(
parse_resp=httpx.Response(500, content=_parsing_failed(reason).encode())
)
with pytest.raises(TransientError):
await t.parse_document(document=_PDF_BODY, source=_source(), call_id="c1")
async def test_success_false_is_rejected_with_status_200(self):
t = self._transport(
parse_resp=httpx.Response(200, json={"success": False, "message": "no"})
)
with pytest.raises(RequestRejectedError) as ei:
await t.parse_document(document=_PDF_BODY, source=_source(), call_id="c1")
assert ei.value.status_code == 200
async def test_missing_download_url_and_non_json_are_transient(self):
body = {"success": True, "message": "ok"}
for resp in (httpx.Response(200, json=body), httpx.Response(200, content=b"not json")):
t = self._transport(parse_resp=resp)
with pytest.raises(TransientError):
await t.parse_document(document=_PDF_BODY, source=_source(), call_id="c1")
@pytest.mark.parametrize(
("status", "exc_type"),
[
(401, SourceDeadError),
(429, TransientError),
(500, TransientError),
(404, RequestRejectedError),
],
)
async def test_download_errors_keep_their_class_and_body(self, status, exc_type):
"""GET 阶段不适用确定性窄化,且响应体摘要随每个分支(issue #10)。"""
body = _parsing_failed(_DEFAULT_REJECTED_MARKERS[0])
t = self._transport(zip_resp=httpx.Response(status, content=body.encode()))
with pytest.raises(exc_type) as ei:
await t.parse_document(document=_PDF_BODY, source=_source(), call_id="c1")
assert ei.value.status_code == status and ei.value.body_text == body
async def test_huge_error_body_is_read_only_up_to_a_bound(self):
"""摘要只需要几 KiB;服务(或中间人)回一个巨大的错误体不该被整个读进内存。
(独立审查实测:600 MiB 的 500 响应让进程 RSS 涨到 1.8 GiB——新代码声称"流式 + 体积上限",
错误分支却是例外。)
"""
consumed = []
async def endless_error_body():
for _ in range(100_000):
consumed.append(1)
yield b"e" * 1024
t = self._transport(zip_resp=httpx.Response(500, content=endless_error_body()))
with pytest.raises(TransientError) as ei:
await t.parse_document(document=_PDF_BODY, source=_source(), call_id="c1")
assert ei.value.status_code == 500
assert len(consumed) <= monkey_ocr._ERROR_BODY_READ_CAP // 1024 + 2 # 只读了上限那么多
assert ei.value.body_text.startswith("e" * 100) # 摘要仍在
async def test_error_status_with_oversized_content_length_is_still_classified_by_http(
self, monkeypatch
):
"""结果包资源检查不得改变错误分类: 超限的 Content-Length 出现在 401 上,仍是 SourceDead。"""
monkeypatch.setattr(monkey_ocr, "_MAX_PACKAGE_BYTES", 100)
resp = httpx.Response(401, content=b"denied", headers={"content-length": "101"})
with pytest.raises(SourceDeadError):
await self._transport(zip_resp=resp).parse_document(
document=_PDF_BODY, source=_source(), call_id="c1"
)
@staticmethod
def _oversized_valid_package():
"""**合法**的 ZIP 但整体超过上限: 去掉上限检查时它能被正常解析,用例才会转红。
(独立验证发现: 旧用例用非 ZIP 垃圾字节,去掉上限后同样抛 ResultInvalidError——
走的是 BadZipFile 路径,上限从未被测到。)
"""
return _doc_package(
[_doc_page([_text_block("SYNTH-A")])], extra=[("images/big.jpg", b"x" * 5000)]
)
async def _assert_cap_rejects_before_parsing(self, monkeypatch, resp):
monkeypatch.setattr(monkey_ocr, "_MAX_PACKAGE_BYTES", 2000)
parsed = []
def spy(package):
parsed.append(len(package))
return [], [(1.0, 1.0)], ""
monkeypatch.setattr(monkey_ocr, "parse_document_package", spy)
with pytest.raises(ResultInvalidError, match="体积超过上限"):
await self._transport(zip_resp=resp).parse_document(
document=_PDF_BODY, source=_source(), call_id="c1"
)
assert parsed == [] # 解析函数根本没被调用
async def test_oversized_valid_package_is_rejected_by_declared_content_length(
self, monkeypatch
):
resp = httpx.Response(200, content=self._oversized_valid_package()) # 自动带 Content-Length
await self._assert_cap_rejects_before_parsing(monkeypatch, resp)
async def test_oversized_valid_package_is_rejected_by_cumulative_count(self, monkeypatch):
"""没有 Content-Length(分块传输)时靠累计字节兜底。"""
package = self._oversized_valid_package()
async def chunks():
for offset in range(0, len(package), 700):
yield package[offset : offset + 700]
await self._assert_cap_rejects_before_parsing(
monkeypatch, httpx.Response(200, content=chunks())
)
async def test_package_under_the_cap_still_parses(self, monkeypatch):
"""对照: 上限只拦超限的,不误伤正常包。"""
monkeypatch.setattr(monkey_ocr, "_MAX_PACKAGE_BYTES", 10_000)
result = await self._transport(
zip_resp=httpx.Response(200, content=self._oversized_valid_package())
).parse_document(document=_PDF_BODY, source=_source(), call_id="c1")
assert [e.text for e in result.elements] == ["SYNTH-A"]
@pytest.mark.parametrize(
("status", "exc_type"),
[
(401, SourceDeadError),
(404, RequestRejectedError),
(500, TransientError),
(302, RequestRejectedError), # 3xx 不是 `is_error`,但 raise_for_status 会抛
],
)
async def test_streamed_error_body_is_read_before_classification(self, status, exc_type):
"""真正的流式响应(异步迭代器 body)不会被预缓冲: 不先 `aread()`,摘要读到的是空串。
(独立验证发现: `httpx.Response(content=bytes)` 已预缓冲,删掉 `aread()` 后旧用例仍绿;
且旧条件 `is_error` 漏掉 3xx。)
"""
body = '{"detail": "synthetic upstream reason"}'
async def streamed():
yield body.encode()
t = self._transport(zip_resp=httpx.Response(status, content=streamed()))
with pytest.raises(exc_type) as ei:
await t.parse_document(document=_PDF_BODY, source=_source(), call_id="c1")
assert ei.value.status_code == status
assert ei.value.body_text == body
async def test_bad_package_is_result_invalid(self):
t = self._transport(zip_resp=httpx.Response(200, content=b"not a zip"))
with pytest.raises(ResultInvalidError):
await t.parse_document(document=_PDF_BODY, source=_source(), call_id="c1")
async def test_cancellation_during_parse_does_not_block_the_event_loop(self, monkeypatch):
"""解析在线程里跑: 同步解析大对象会占住事件循环,取消与 call_deadline_s 都会失效。
线程内用 threading.Event 阻塞,并用 call_soon_threadsafe 通知(线程里不能直接用
asyncio.Event)。若实现退回同步解析,桩会把循环占满 1 秒后正常返回,
`task.cancel()` 就取不到 CancelledError——用例转红。
"""
loop = asyncio.get_running_loop()
entered = asyncio.Event()
release = threading.Event()
def blocking_parse(zip_bytes):
loop.call_soon_threadsafe(entered.set)
release.wait(timeout=1)
return [], [(1.0, 1.0)], ""
monkeypatch.setattr(monkey_ocr, "parse_document_package", blocking_parse)
task = asyncio.create_task(
self._transport().parse_document(document=_PDF_BODY, source=_source(), call_id="c1")
)
try:
await asyncio.wait_for(entered.wait(), timeout=2)
await asyncio.sleep(0.01) # 线程还卡着,循环必须仍能调度别的协程
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
finally:
release.set() # 让线程收尾,不泄漏
class TestCheckHealth: class TestCheckHealth:
async def test_healthy(self): async def test_healthy(self):
t = _transport_for(_routes(health_resp=httpx.Response(200, json={"status": "healthy"}))) t = _transport_for(_routes(health_resp=httpx.Response(200, json={"status": "healthy"})))
+501 -5
View File
@@ -5,7 +5,9 @@ retry_exhausted/circuit_open/stalled 三组断言即设计 §6 ③ 的 G1 契约
""" """
import asyncio import asyncio
from types import SimpleNamespace
import httpx
import pytest import pytest
from loguru import logger from loguru import logger
@@ -13,6 +15,7 @@ from polygateway.backends.memory.breaker import InMemoryGate
from polygateway.backends.memory.limiter import InMemoryLimiter from polygateway.backends.memory.limiter import InMemoryLimiter
from polygateway.errors import ( from polygateway.errors import (
AllSourcesExhausted, AllSourcesExhausted,
CallDeadlineExceeded,
CircuitOpenError, CircuitOpenError,
RequestRejectedError, RequestRejectedError,
ResultInvalidError, ResultInvalidError,
@@ -20,20 +23,34 @@ from polygateway.errors import (
TransientError, TransientError,
) )
from polygateway.ocr import OcrClient from polygateway.ocr import OcrClient
from polygateway.transports.monkey_ocr import MonkeyOcrTransport
from polygateway.types import ( from polygateway.types import (
BackpressurePolicy, BackpressurePolicy,
BreakerConfig, BreakerConfig,
GlobalLimits, GlobalLimits,
OcrDocumentElement,
OcrDocumentTransportResult,
OcrLayoutElement, OcrLayoutElement,
OcrLayoutTransportResult, OcrLayoutTransportResult,
OcrTextTransportResult, OcrTextTransportResult,
RetryPolicy, RetryPolicy,
SourceConfig, SourceConfig,
) )
from tests.unit import _document_fixtures as fx
_BREAKER = BreakerConfig(fail_threshold=3, cooldown_s=60.0, probe_ttl_s=120.0) _BREAKER = BreakerConfig(fail_threshold=3, cooldown_s=60.0, probe_ttl_s=120.0)
_NO_GLOBAL = GlobalLimits(max_concurrency=0, rpm=0, tpm=0) _NO_GLOBAL = GlobalLimits(max_concurrency=0, rpm=0, tpm=0)
_TEXT_OK = OcrTextTransportResult(text="LINE-1", raw={"task_type": "text"}) _TEXT_OK = OcrTextTransportResult(text="LINE-1", raw={"task_type": "text"})
_DOC_OK = OcrDocumentTransportResult(
elements=[
OcrDocumentElement(
type="text", bbox=(1.0, 2.0, 3.0, 4.0), page_index=0, text="SYNTH-DOC-BODY"
)
],
page_sizes=[(612.0, 792.0)],
markdown="# SYNTH-MD",
raw={"success": True},
)
_LAYOUT_OK = OcrLayoutTransportResult( _LAYOUT_OK = OcrLayoutTransportResult(
elements=[OcrLayoutElement(type="table", bbox=(41.0, 48.0, 218.0, 282.0), page_index=0)], elements=[OcrLayoutElement(type="table", bbox=(41.0, 48.0, 218.0, 282.0), page_index=0)],
page_sizes=[(759.0, 540.0)], page_sizes=[(759.0, 540.0)],
@@ -60,6 +77,8 @@ class ScriptedOcrTransport:
def __init__(self, script): def __init__(self, script):
self.script = list(script) self.script = list(script)
self.calls = [] self.calls = []
# 取消用例的确定性窗口: 进入 hang 分支即置位, 不用 sleep 撞窗口
self.entered = asyncio.Event()
async def _next(self, method, source, call_id): async def _next(self, method, source, call_id):
self.calls.append((method, source.name, call_id)) self.calls.append((method, source.name, call_id))
@@ -67,8 +86,13 @@ class ScriptedOcrTransport:
if isinstance(action, Exception): if isinstance(action, Exception):
raise action raise action
if action == "hang": if action == "hang":
self.entered.set()
await asyncio.Event().wait() await asyncio.Event().wait()
return _TEXT_OK if action == "text" else _LAYOUT_OK if action == "text":
return _TEXT_OK
if action == "document":
return _DOC_OK
return _LAYOUT_OK
async def recognize_text(self, *, image, source, call_id): async def recognize_text(self, *, image, source, call_id):
return await self._next("text", source, call_id) return await self._next("text", source, call_id)
@@ -76,6 +100,9 @@ class ScriptedOcrTransport:
async def parse_layout(self, *, image, source, call_id): async def parse_layout(self, *, image, source, call_id):
return await self._next("layout", source, call_id) return await self._next("layout", source, call_id)
async def parse_document(self, *, document, source, call_id):
return await self._next("document", source, call_id)
async def check_health(self, *, source): async def check_health(self, *, source):
raise NotImplementedError raise NotImplementedError
@@ -192,6 +219,21 @@ class TestSuccessPaths:
await client.parse_layout(b"") await client.parse_layout(b"")
class TestOcrGenerationMs:
"""OCR 裸生成时间单次覆盖(1.3.7 H8): 计时只包 transport 调用本身。"""
async def test_generation_ms_single_transport_call(self):
# 0.5s 为二进制可精确表示值: int 截断下非精确值会因浮点误差少 1ms
clock = FakeClock()
transport = ClockAdvancingOcrTransport([(0.5, "text")], clock)
client, _, _ = _client([_src()], [], now=clock, transport=transport)
r = await client.recognize_text(b"jpg")
assert r.text == "LINE-1"
assert r.call_stats is not None
assert r.call_stats.attempts == 1
assert r.call_stats.generation_ms == 500
class TestFailover: class TestFailover:
async def test_transient_retries_with_backoff(self): async def test_transient_retries_with_backoff(self):
sleeps = [] sleeps = []
@@ -395,6 +437,20 @@ class TestCancellation:
stats = await limiter.source_stats("m1") stats = await limiter.source_stats("m1")
assert stats.inflight == 0 # permit 在 finally 释放 assert stats.inflight == 0 # permit 在 finally 释放
async def test_cancel_in_flight_still_settles_zero(self):
"""S6: OCR 的 0 token 是**事实**而非"未知", 取消也不得改成按 est 结算。"""
transport = ScriptedOcrTransport(["hang"])
client, limiter, _ = _client(
[_src(max_concurrency=1, tpm=1000, est_tokens=400)], [], transport=transport
)
task = asyncio.create_task(client.recognize_text(b"jpg"))
await transport.entered.wait()
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
stats = await limiter.source_stats("m1")
assert stats.tpm_used == 0 and stats.inflight == 0
class TestCheckHealth: class TestCheckHealth:
class _HealthTransport(ScriptedOcrTransport): class _HealthTransport(ScriptedOcrTransport):
@@ -515,7 +571,7 @@ class TestOcrCallerDimensions:
assert [r["tenant_id"] for r in recorder.rows] == ["t1", "t1"] assert [r["tenant_id"] for r in recorder.rows] == ["t1", "t1"]
assert [r["meta"] for r in recorder.rows] == ['{"batch": "b-42"}'] * 2 assert [r["meta"] for r in recorder.rows] == ['{"batch": "b-42"}'] * 2
@pytest.mark.parametrize("method", ["recognize_text", "parse_layout"]) @pytest.mark.parametrize("method", ["recognize_text", "parse_layout", "parse_document"])
async def test_invalid_meta_rejected_before_any_telemetry(self, method): async def test_invalid_meta_rejected_before_any_telemetry(self, method):
"""校验必须早于遥测: 链路内的失败都被降级成 warning,放下游等于没有校验。""" """校验必须早于遥测: 链路内的失败都被降级成 warning,放下游等于没有校验。"""
recorder = _MemoryRecorder() recorder = _MemoryRecorder()
@@ -525,6 +581,14 @@ class TestOcrCallerDimensions:
assert recorder.rows == [] assert recorder.rows == []
assert client._transport.calls == [] # 连调用都没发出 assert client._transport.calls == [] # 连调用都没发出
async def test_parse_document_row_carries_dimensions(self):
"""第三个入口: 只测前两个会漏掉它的透传缺口。"""
recorder = _MemoryRecorder()
client, _, _ = _client([_src()], ["document"], telemetry=recorder)
await client.parse_document(b"%PDF-1.4", tenant_id="t3", meta={"batch": "b-44"})
assert recorder.rows[0]["tenant_id"] == "t3"
assert recorder.rows[0]["meta"] == '{"batch": "b-44"}'
async def test_defaults_land_as_sentinels(self): async def test_defaults_land_as_sentinels(self):
recorder = _MemoryRecorder() recorder = _MemoryRecorder()
client, _, _ = _client([_src()], ["text"], telemetry=recorder) client, _, _ = _client([_src()], ["text"], telemetry=recorder)
@@ -533,6 +597,408 @@ class TestOcrCallerDimensions:
assert recorder.rows[0]["meta"] == "{}" assert recorder.rows[0]["meta"] == "{}"
class _LegacyOcrTransport:
"""只实现旧 `OcrTransport` 三方法的下游 fake: 不满足 `OcrDocumentTransport`。"""
def __init__(self):
self.calls = []
async def recognize_text(self, *, image, source, call_id):
self.calls.append("text")
return _TEXT_OK
async def parse_layout(self, *, image, source, call_id):
self.calls.append("layout")
return _LAYOUT_OK
async def check_health(self, *, source):
return True
class TestParseDocument:
"""#34 设计 §4.2/§6: 与 parse_layout 对称的整份文档入口,治理循环原样复用。"""
async def test_result_fields(self):
client, _, gate = _client([_src()], ["document"])
r = await client.parse_document(b"%PDF-1.4")
assert [e.text for e in r.elements] == ["SYNTH-DOC-BODY"]
assert r.markdown == "# SYNTH-MD" and r.page_sizes == [(612.0, 792.0)]
assert r.source_name == "m1"
assert r.usage.prompt_tokens == 0 and r.usage.completion_tokens == 0
assert r.latency_ms >= 0 and r.call_id and r.raw == {"success": True}
assert r.call_stats is not None and r.call_stats.attempts == 1
assert gate.successes == [("m1", True)]
assert client._selector.outcomes == [("m1", True)]
async def test_input_validation_names_the_document(self):
client, _, _ = _client([_src()], [])
with pytest.raises(TypeError, match="document 必须是 bytes"):
await client.parse_document("not-bytes")
with pytest.raises(ValueError, match="document 不能为空"):
await client.parse_document(b"")
async def test_legacy_entrances_keep_their_error_text(self):
"""私有形参改名为 payload 不得改变旧入口的报错文案(含 D9 的业务边界提示)。"""
client, _, _ = _client([_src()], [])
with pytest.raises(TypeError, match=r"image 必须是 bytes\(路径读取/批量拼帧留业务侧,D9\)"):
await client.recognize_text("not-bytes")
with pytest.raises(ValueError, match="image 不能为空"):
await client.parse_layout(b"")
async def test_transport_without_document_support_is_an_assembly_error(self):
"""装配错误当场报,且早于统计边界: 没有遥测行、没有任何 transport 调用。"""
recorder = _MemoryRecorder()
legacy = _LegacyOcrTransport()
client, limiter, _ = _client([_src(rpm=100)], [], transport=legacy, telemetry=recorder)
with pytest.raises(TypeError, match="OcrDocumentTransport"):
await client.parse_document(b"%PDF-1.4")
assert recorder.rows == [] and legacy.calls == []
# 早于准入: 从未向限流后端申请过许可(否则 rpm 已被占用一次)
assert (await limiter.source_stats("m1")).rpm_used == 0
async def test_invalid_call_deadline_is_rejected_like_the_other_entrances(self):
client, _, _ = _client([_src()], ["document"])
with pytest.raises(ValueError, match="call_deadline_s"):
await client.parse_document(b"%PDF-1.4", call_deadline_s=-1)
async def test_transient_retries_then_succeeds(self):
client, _, _ = _client(
[_src()],
[TransientError("boom", status_code=500), "document"],
sleep=lambda _d: asyncio.sleep(0),
)
r = await client.parse_document(b"%PDF-1.4")
assert r.markdown == "# SYNTH-MD"
assert client._selector.outcomes == [("m1", False), ("m1", True)]
async def test_source_dead_switches_to_the_second_source_immediately(self):
s1, s2 = _src(name="m1"), _src(name="m2", base_url="http://10.77.0.20:7867")
client, _, _ = _client(
[s1, s2], [SourceDeadError("401", status_code=401), "document"], sleep=_no_sleep
)
r = await client.parse_document(b"%PDF-1.4")
assert r.source_name == "m2"
assert client._selector.outcomes == [("m1", False), ("m2", True)]
async def test_result_invalid_counts_no_attempt_and_no_feed(self):
client, _, gate = _client([_src()], [ResultInvalidError("bad package")])
with pytest.raises(ResultInvalidError):
await client.parse_document(b"%PDF-1.4")
assert gate.successes == [("m1", False)]
assert client._selector.outcomes == []
async def test_deterministic_rejection_with_status_500_counts_as_a_live_service(self):
"""服务有响应(500 + 确定性坏输入标记)= 服务活着: 记成功不入窗口,不喂选源器。"""
client, _, gate = _client(
[_src()], [RequestRejectedError("Parsing failed: is no PDF", status_code=500)]
)
with pytest.raises(RequestRejectedError):
await client.parse_document(b"%PDF-1.4")
assert gate.successes == [("m1", False)]
assert client._selector.outcomes == []
async def test_local_rejection_does_not_touch_the_breaker(self):
client, _, gate = _client([_src()], [RequestRejectedError("unrecognizable")])
with pytest.raises(RequestRejectedError):
await client.parse_document(b"%PDF-1.4")
assert gate.successes == []
async def test_rate_limited_responses_spend_retry_budget_but_not_breaker_failures(self):
"""429 是背压不是故障(熔断两通道均不计): 三次 429 耗尽重试后,熔断仍是 CLOSED。"""
client, _, gate = _client(
[_src()],
[TransientError("busy", status_code=429)] * 3,
sleep=lambda _d: asyncio.sleep(0),
)
with pytest.raises(AllSourcesExhausted):
await client.parse_document(b"%PDF-1.4")
entry = await gate._inner.try_enter("m1", "check")
assert entry.allowed and not entry.is_probe
async def test_read_timeouts_are_counted_as_source_failures(self):
"""对照: 文档路径的 httpx 超时仍是瞬时故障、计熔断(`timeout_s` 不是免责额度)。"""
def timeout_exc():
exc = TransientError("timed out", operation="parse")
exc.__cause__ = httpx.ReadTimeout("read timed out")
return exc
client, _, gate = _client(
[_src()], [timeout_exc() for _ in range(3)], sleep=lambda _d: asyncio.sleep(0)
)
with pytest.raises(AllSourcesExhausted) as ei:
await client.parse_document(b"%PDF-1.4")
assert ei.value.per_source_reasons == {"m1": "timeout"}
entry = await gate._inner.try_enter("m1", "check")
assert not entry.allowed # 阈值 3 的熔断已开路
async def test_cancel_releases_permit_and_probe(self):
clock = FakeClock()
inner = InMemoryGate(config=_BREAKER, now=clock)
gate = RecordingGate(inner)
transport = ScriptedOcrTransport(["hang"])
client, limiter, _ = _client(
[_src(max_concurrency=1)], [], now=clock, breaker=gate, transport=transport
)
entry = await inner.try_enter("m1", "setup")
await inner.record_failure(entry, "source_dead", True)
clock.advance(61.0) # 越过冷却 → 半开探针
task = asyncio.create_task(client.parse_document(b"%PDF-1.4"))
await transport.entered.wait()
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert gate.probe_releases >= 1
assert (await limiter.source_stats("m1")).inflight == 0
async def _no_sleep(_delay):
await asyncio.sleep(0)
class TestNoLatencyJudgement:
"""长文档不会被判慢(设计 §7): 库内没有任何基于延迟的熔断/选源判定。
时钟只在 transport 内前进(`ClockAdvancingOcrTransport`),门与 client 共用同一注入钟。
probe_ttl_s 取极大值,避免把探针租约接管混进"慢"的断言。
"""
_CFG = BreakerConfig(fail_threshold=3, cooldown_s=60.0, probe_ttl_s=1e12)
async def test_slow_success_leaves_breaker_closed_with_no_failures(self):
clock = FakeClock()
gate = RecordingGate(InMemoryGate(config=self._CFG, now=clock))
transport = ClockAdvancingOcrTransport([(100_000.0, "document")], clock)
client, _, _ = _client(
[_src(timeout_s=120.0)], [], now=clock, breaker=gate, transport=transport
)
r = await client.parse_document(b"%PDF-1.4")
assert r.call_stats.attempts == 1 and r.call_stats.generation_ms >= 100_000_000
assert gate._inner._gates["m1"].fails == 0
assert gate.successes == [("m1", True)]
async def test_slow_success_clears_pre_existing_failure_count(self):
"""前置状态不是初始 CLOSED: 已有 2 次失败(阈值 3),一次很慢的成功也只会清零。"""
clock = FakeClock()
inner = InMemoryGate(config=self._CFG, now=clock)
for _ in range(2):
entry = await inner.try_enter("m1", "setup")
await inner.record_failure(entry, "network_error", False)
assert inner._gates["m1"].fails == 2
transport = ClockAdvancingOcrTransport([(100_000.0, "document")], clock)
client, _, _ = _client(
[_src()], [], now=clock, breaker=RecordingGate(inner), transport=transport
)
await client.parse_document(b"%PDF-1.4")
assert inner._gates["m1"].fails == 0
async def test_slow_success_on_the_half_open_probe_closes_the_breaker(self):
clock = FakeClock()
inner = InMemoryGate(config=self._CFG, now=clock)
entry = await inner.try_enter("m1", "setup")
await inner.record_failure(entry, "source_dead", True) # OPEN
clock.advance(61.0) # → HALF_OPEN
transport = ClockAdvancingOcrTransport([(100_000.0, "document")], clock)
client, _, _ = _client(
[_src()], [], now=clock, breaker=RecordingGate(inner), transport=transport
)
await client.parse_document(b"%PDF-1.4")
after = await inner.try_enter("m1", "check")
assert after.allowed and not after.is_probe # 探针成功 → CLOSED,而不是被慢耗尽
class TestDocumentTelemetry:
async def test_success_row(self):
recorder = _MemoryRecorder()
client, _, _ = _client([_src()], ["document"], telemetry=recorder)
await client.parse_document(b"%PDF-RAW-BYTES")
(row,) = recorder.rows
assert row["operation"] == "parse_document"
assert "<ocr:document image_bytes=14>" in row["messages"]
assert "RAW-BYTES" not in row["messages"] # 文档字节绝不入库
assert row["response"] == "<document elements=1 pages=1 md_chars=10>"
assert "SYNTH-MD" not in row["response"] and "SYNTH-DOC-BODY" not in row["response"]
assert row["reasoning_effort"] is None # `reasoning_applies=False` 的落库形态
assert row["error"] is None and row["prompt_tokens"] == 0
async def test_summary_distinguishes_the_three_result_kinds(self):
"""新结果类型必须在 `_summarize` 里显式区分,否则会被静默套成 layout 摘要而漏记 md_chars。"""
assert OcrClient._summarize(_DOC_OK) == "<document elements=1 pages=1 md_chars=10>"
assert OcrClient._summarize(_LAYOUT_OK) == "<elements n=1 pages=1>"
assert OcrClient._summarize(_TEXT_OK) == "LINE-1"
def test_summary_keeps_structural_dispatch_for_duck_typed_results(self):
"""旧实现按结构分派(`hasattr(result, "text")`)。下游测试替身可能返回非库类型的鸭子对象,
改成 isinstance 会让它们在开启遥测后以原生 AttributeError 漏出——无登记的回归。"""
assert OcrClient._summarize(SimpleNamespace(text="hello", raw={})) == "hello"
assert (
OcrClient._summarize(SimpleNamespace(elements=[1, 2], page_sizes=[(1.0, 1.0)]))
== "<elements n=2 pages=1>"
)
assert (
OcrClient._summarize(
SimpleNamespace(elements=[1], page_sizes=[(1.0, 1.0)], markdown="abc")
)
== "<document elements=1 pages=1 md_chars=3>"
)
async def test_failed_attempt_row_carries_the_class_prefix(self):
recorder = _MemoryRecorder()
client, _, _ = _client([_src()], [ResultInvalidError("bad package")], telemetry=recorder)
with pytest.raises(ResultInvalidError):
await client.parse_document(b"%PDF-1.4")
assert any(r["error"].startswith("ResultInvalidError:") for r in recorder.rows)
assert {r["operation"] for r in recorder.rows} == {"parse_document"}
def _real_transport(handler):
return MonkeyOcrTransport(
client_factory=lambda src: httpx.AsyncClient(
base_url=src.base_url, transport=httpx.MockTransport(handler)
)
)
def _doc_handler(package: bytes | None = None, *, download=None):
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path == "/parse":
return httpx.Response(
200,
json={"success": True, "message": "ok", "download_url": "/static/doc.zip"},
)
if download is not None:
return download()
return httpx.Response(200, content=package)
return handler
class TestDocumentThroughTheRealTransport:
"""真实 `MonkeyOcrTransport` + `MockTransport` + 真实 `OcrClient`: 证明异常/期限贯穿整条链。
MockTransport 不执行 httpcore 的逐次读超时,所以本组只证明**治理期限与异常分类**,
不证明 read timeout 的真实行为(后者留给活体用例)。
"""
async def test_happy_path_end_to_end(self):
package = fx._package([fx._page([fx._text_block("SYNTH-E2E")])])
client, _, _ = _client([_src()], [], transport=_real_transport(_doc_handler(package)))
r = await client.parse_document(b"%PDF-1.4 synthetic")
assert [e.text for e in r.elements] == ["SYNTH-E2E"]
assert r.markdown == "# synth\n"
@pytest.mark.parametrize("package_kind", ["huge-int", "encrypted"])
async def test_parser_exceptions_become_result_invalid_with_full_bookkeeping(
self, package_kind
):
"""真实解析器抛出的异常(OverflowError / RuntimeError)经真实客户端: 终态遥测行已写、
半开探针已归还、熔断窗口不变——不能只用桩直接抛预制的 `ResultInvalidError`。"""
if package_kind == "huge-int":
block = fx._text_block()
block["bbox"] = [0, 0, 10**400, 1]
package = fx._package([fx._page([block])])
else:
package = fx._encrypted_package()
clock = FakeClock()
inner = InMemoryGate(
config=BreakerConfig(fail_threshold=3, cooldown_s=60.0, probe_ttl_s=120.0), now=clock
)
entry = await inner.try_enter("m1", "setup")
await inner.record_failure(entry, "source_dead", True)
clock.advance(61.0) # 半开: 本次调用持有探针
recorder = _MemoryRecorder()
client, _, gate = _client(
[_src()],
[],
now=clock,
breaker=RecordingGate(inner),
telemetry=recorder,
transport=_real_transport(_doc_handler(package)),
)
with pytest.raises(ResultInvalidError):
await client.parse_document(b"%PDF-1.4 synthetic")
assert any(r["error"].startswith("ResultInvalidError:") for r in recorder.rows)
after = await inner.try_enter("m1", "check")
assert after.allowed and not after.is_probe # 探针已按"坏结果≠坏服务"结算,没有悬挂
assert inner._gates["m1"].fails == 0
@staticmethod
def _slow_download_handler():
async def slow_body():
for _ in range(200):
await asyncio.sleep(0.01)
yield b"x" * 8
return _doc_handler(download=lambda: httpx.Response(200, content=slow_body()))
async def test_per_call_deadline_overrides_the_assembly_value(self):
client, _, _ = _client(
[_src(timeout_s=120.0)], [], transport=_real_transport(self._slow_download_handler())
) # 装配值未设期限
with pytest.raises(CallDeadlineExceeded):
await client.parse_document(b"%PDF-1.4 synthetic", call_deadline_s=0.1)
async def test_deadline_while_holding_the_half_open_probe_returns_it_and_writes_a_terminal_row(
self,
):
"""期限到期取消在飞请求时: 探针必须归还,且终态遥测行必须写出(遥测必录)。"""
clock = FakeClock()
inner = InMemoryGate(
config=BreakerConfig(fail_threshold=3, cooldown_s=60.0, probe_ttl_s=120.0), now=clock
)
entry = await inner.try_enter("m1", "setup")
await inner.record_failure(entry, "source_dead", True)
clock.advance(61.0) # 半开: 本次调用持有探针
gate = RecordingGate(inner)
recorder = _MemoryRecorder()
client, _, _ = _client(
[_src(timeout_s=120.0)],
[],
now=clock,
breaker=gate,
telemetry=recorder,
transport=_real_transport(self._slow_download_handler()),
call_deadline_s=0.1,
)
with pytest.raises(CallDeadlineExceeded):
await client.parse_document(b"%PDF-1.4 synthetic")
assert gate.probe_releases >= 1
again = await inner.try_enter("m1", "check")
assert again.allowed and again.is_probe # 探针没有悬挂,下一位调用方可以再探
terminals = [r for r in recorder.rows if r["event_kind"] == "terminal_failure"]
assert len(terminals) == 1 and terminals[0]["operation"] == "parse_document"
async def test_sustained_slow_download_hits_the_call_deadline_not_a_transient_error(self):
"""每个分块都在 read 超时内、总时长超过 call_deadline_s: 是期限,不是瞬时故障。"""
closed = []
produced = []
async def slow_body():
try:
for _ in range(200):
await asyncio.sleep(0.01)
produced.append(1)
yield b"x" * 8
finally:
closed.append(True)
handler = _doc_handler(download=lambda: httpx.Response(200, content=slow_body()))
client, limiter, gate = _client(
[_src(max_concurrency=1, timeout_s=120.0)],
[],
transport=_real_transport(handler),
call_deadline_s=0.1,
)
with pytest.raises(CallDeadlineExceeded):
await client.parse_document(b"%PDF-1.4 synthetic")
assert len(produced) >= 2 # 确实进入了多块下载,而不是一开始就超时
assert closed == [True] # 流已关闭
assert (await limiter.source_stats("m1")).inflight == 0 # permit 释放
assert gate.successes == [] # 期限不是服务故障也不是成功
entry = await gate._inner.try_enter("m1", "check")
assert entry.allowed and not entry.is_probe # 熔断窗口不变
class TestAssembly: class TestAssembly:
_ENV = { _ENV = {
"OCR__MONKEY__1__BASE_URL": "http://10.77.0.20:7866", "OCR__MONKEY__1__BASE_URL": "http://10.77.0.20:7866",
@@ -567,10 +1033,14 @@ class TestAssembly:
class TestReasonlessTelemetryContract: class TestReasonlessTelemetryContract:
"""text/layout 两个入口分别验证错误行不受源级推理配置污染。""" """text/layout/document 三个入口分别验证错误行不受源级推理配置污染。
独立验证发现文档入口原先不在矩阵里: 让 OCR 一律按 `reasoning_applies=True` 记账,
15 个旧用例转红,但没有一个是 `parse_document` 的。"""
@pytest.mark.parametrize( @pytest.mark.parametrize(
"method,action", [("recognize_text", "text"), ("parse_layout", "layout")] "method,action",
[("recognize_text", "text"), ("parse_layout", "layout"), ("parse_document", "document")],
) )
@pytest.mark.parametrize("config", [{"enable_thinking": True}, {"reasoning_effort": "high"}]) @pytest.mark.parametrize("config", [{"enable_thinking": True}, {"reasoning_effort": "high"}])
@pytest.mark.parametrize("backend", ["memory", "sqlite"]) @pytest.mark.parametrize("backend", ["memory", "sqlite"])
@@ -603,7 +1073,7 @@ class TestReasonlessTelemetryContract:
if backend == "sqlite": if backend == "sqlite":
recorder.close() recorder.close()
@pytest.mark.parametrize("method", ["recognize_text", "parse_layout"]) @pytest.mark.parametrize("method", ["recognize_text", "parse_layout", "parse_document"])
@pytest.mark.parametrize("exhausted", [False, True]) @pytest.mark.parametrize("exhausted", [False, True])
async def test_failed_attempts_still_have_null_effort(self, method, exhausted): async def test_failed_attempts_still_have_null_effort(self, method, exhausted):
script = ( script = (
@@ -652,3 +1122,29 @@ class TestOcrLogicalCallStats:
await client.recognize_text("not-bytes") await client.recognize_text("not-bytes")
with pytest.raises(ValueError): with pytest.raises(ValueError):
await client.recognize_text(b"") await client.recognize_text(b"")
class TestOcrCallDeadline:
"""OCR 两个公开入口的期限与 per-call 校验(计划 §3.4/§5 批次 D/E)。"""
async def test_expiry_on_a_hanging_transport(self):
client, limiter, _ = _client([_src()], ["hang"])
with pytest.raises(CallDeadlineExceeded) as exc:
await client.recognize_text(b"jpg", call_deadline_s=0.05)
assert exc.value.scope == "ocr" and exc.value.deadline_s == 0.05
# 清理照常在 finally 完成: 在途计数必须归零(OCR 无 token,结算恒 0)
stats = await limiter.source_stats("m1")
assert stats.inflight == 0 and stats.tpm_used == 0
@pytest.mark.parametrize("method", ["recognize_text", "parse_layout"])
async def test_illegal_per_call_value_names_the_entry_it_came_from(self, method):
"""两个入口各自报自己的名字: 多入口部署里才定位得到是哪次调用传错了。"""
transport = ScriptedOcrTransport([])
client, _, _ = _client([_src()], [], transport=transport)
with pytest.raises(ValueError, match=rf"{method}\(call_deadline_s"):
await getattr(client, method)(b"jpg", call_deadline_s=float("inf"))
assert transport.calls == []
def test_illegal_constructor_value_is_rejected_at_assembly(self):
with pytest.raises(ValueError, match=r"OcrClient\(call_deadline_s"):
_client([_src()], [], call_deadline_s=0)
+365 -5
View File
@@ -3,6 +3,7 @@
SSE 帧样本按三项目真实网关响应形态二次构造(OpenAI 兼容 chunk 结构)。 SSE 帧样本按三项目真实网关响应形态二次构造(OpenAI 兼容 chunk 结构)。
""" """
import asyncio
import json import json
import httpx import httpx
@@ -14,6 +15,7 @@ from polygateway.errors import (
SourceDeadError, SourceDeadError,
TransientError, TransientError,
) )
from polygateway.middleware.retry import backoff_delay
from polygateway.middleware.telemetry import TelemetryEmitter from polygateway.middleware.telemetry import TelemetryEmitter
from polygateway.pricing import ModelPrice, PricingTable from polygateway.pricing import ModelPrice, PricingTable
from polygateway.providers import ProviderProfile, ThinkingWire, register_provider from polygateway.providers import ProviderProfile, ThinkingWire, register_provider
@@ -21,9 +23,19 @@ from polygateway.transports._http_errors import summarize_body
from polygateway.transports.openai_compat import ( from polygateway.transports.openai_compat import (
OpenAICompatTransport, OpenAICompatTransport,
_iter_sse_deltas, _iter_sse_deltas,
_parse_retry_after,
_sse_data_payload, _sse_data_payload,
_stall_from_chunk_times,
_translate_429,
)
from polygateway.types import (
ChatRequest,
Effort,
LLMResponse,
RetryPolicy,
SourceConfig,
ThinkingObservation,
) )
from polygateway.types import ChatRequest, Effort, LLMResponse, SourceConfig, ThinkingObservation
def _source(**overrides): def _source(**overrides):
@@ -69,7 +81,17 @@ def _transport_for(handler, *, registry=None):
) )
async def _complete(transport, source, *, stream=True, overlay=None, reasoning_effort=None): async def _complete(
transport,
source,
*,
stream=True,
overlay=None,
reasoning_effort=None,
first_token_event=None,
content_event=None,
response_mode="text",
):
return await transport.complete( return await transport.complete(
messages=[{"role": "user", "content": "hi"}], messages=[{"role": "user", "content": "hi"}],
source=source, source=source,
@@ -77,9 +99,21 @@ async def _complete(transport, source, *, stream=True, overlay=None, reasoning_e
overlay=overlay or {}, overlay=overlay or {},
call_id="cid-1", call_id="cid-1",
reasoning_effort=reasoning_effort, reasoning_effort=reasoning_effort,
first_token_event=first_token_event,
content_event=content_event,
response_mode=response_mode,
) )
async def test_complete_helper_forwards_response_mode():
"""测试封装不得将显式保真模式静默退化成文本模式。"""
from unittest.mock import AsyncMock
transport = AsyncMock()
await _complete(transport, _source(), response_mode="openai")
assert transport.complete.call_args.kwargs["response_mode"] == "openai"
# 单价刻意取"输出贵于输入"的真实形态: est_tokens 兜底把整估值塞进 completion # 单价刻意取"输出贵于输入"的真实形态: est_tokens 兜底把整估值塞进 completion
# 时,虚高才显形(设计 §1 的 26 倍算例即此单价)。 # 时,虚高才显形(设计 §1 的 26 倍算例即此单价)。
_PRICING = PricingTable({"qwen-max": ModelPrice(input_per_1m=1.0, output_per_1m=8.0)}) _PRICING = PricingTable({"qwen-max": ModelPrice(input_per_1m=1.0, output_per_1m=8.0)})
@@ -138,6 +172,8 @@ class TestSsePureFunctions:
assert _sse_data_payload("data: [DONE]") == "[DONE]" assert _sse_data_payload("data: [DONE]") == "[DONE]"
async def test_iter_deltas_yields_and_flags_done(self): async def test_iter_deltas_yields_and_flags_done(self):
from polygateway.transports._openai_completion import ChatCompletionAccumulator, ChatDelta
async def lines(): async def lines():
for raw in [ for raw in [
_chunk(content="he"), _chunk(content="he"),
@@ -150,16 +186,28 @@ class TestSsePureFunctions:
yield line yield line
sink = {} sink = {}
out = [d async for d in _iter_sse_deltas(lines(), sink)] out = [
assert out == [(True, "he"), (False, "think")] d
async for d in _iter_sse_deltas(
lines(), sink, ChatCompletionAccumulator(response_mode="text")
)
]
assert out == [ChatDelta("he", "", True), ChatDelta("", "think", True)]
assert sink["done"] is True and sink["usage"] == _USAGE assert sink["done"] is True and sink["usage"] == _USAGE
async def test_malformed_frame_raises_transient(self): async def test_malformed_frame_raises_transient(self):
from polygateway.transports._openai_completion import ChatCompletionAccumulator
async def lines(): async def lines():
yield "data: {not-json" yield "data: {not-json"
with pytest.raises(TransientError, match="malformed"): with pytest.raises(TransientError, match="malformed"):
_ = [d async for d in _iter_sse_deltas(lines(), {})] _ = [
d
async for d in _iter_sse_deltas(
lines(), {}, ChatCompletionAccumulator(response_mode="text")
)
]
class TestStreamHappyPath: class TestStreamHappyPath:
@@ -204,6 +252,254 @@ class TestStreamHappyPath:
assert await _recorded_cost(result, source) is None assert await _recorded_cost(result, source) is None
class TestFirstTokenEvent:
"""首 token 处置位(1.3.7 对冲 H2): 流式置位、非流式永不置位、None 不观测。"""
async def test_stream_sets_first_token_event(self):
"""流式首 token(内容或思考增量)到达即置位——对冲触发窗的取消信号。"""
def handler(request):
return _sse_stream(
_chunk(reasoning="ponder"), _chunk(content="hi"), _chunk(usage=_USAGE)
)
event = asyncio.Event()
result = await _complete(_transport_for(handler), _source(), first_token_event=event)
assert result.content == "hi"
assert event.is_set()
async def test_non_stream_never_sets_first_token_event(self):
"""非流式物理上无中途信号: 即使调用方给了事件,本路径也永不置位。"""
def handler(request):
return httpx.Response(
200, json={"choices": [{"message": {"content": "42"}}], "usage": _USAGE}
)
event = asyncio.Event()
result = await _complete(
_transport_for(handler), _source(), stream=False, first_token_event=event
)
assert result.content == "42"
assert not event.is_set()
async def test_none_first_token_event_keeps_behavior(self):
"""`None` = 调用方不观测首 token(未启用对冲): 行为与旧版逐字相同。"""
def handler(request):
return _sse_stream(_chunk(content="ok"), _chunk(usage=_USAGE))
result = await _complete(_transport_for(handler), _source(), first_token_event=None)
assert result.content == "ok"
assert result.ttft_ms is not None
async def test_first_token_event_is_required_keyword(self):
"""端口必填约定: 漏传必须 TypeError——默认值会把"漏传"伪装成"不观测"。"""
def handler(request):
return _sse_stream(_chunk(content="ok"), _chunk(usage=_USAGE))
transport = _transport_for(handler)
with pytest.raises(TypeError):
await transport.complete(
messages=[{"role": "user", "content": "hi"}],
source=_source(),
stream=True,
overlay={},
call_id="cid-1",
reasoning_effort=None,
)
class _CountingEvent(asyncio.Event):
"""统计 set() 次数的 Event: content_event "每增量 chunk 置位一次"的观测设施。"""
def __init__(self) -> None:
super().__init__()
self.set_count = 0
def set(self) -> None:
self.set_count += 1
super().set()
def _gated_sse_response(*items):
"""事件驱动的 SSE 响应体: str 段直接产出,asyncio.Event 段等待置位。
停顿由测试体用真实 `asyncio.sleep` 制造(计划 §5 真实 loop 钟范式,4× 余量);
闸门只决定"停顿发生在哪两帧之间",不断定时长,两个方向都不撞窗口。
"""
async def gen():
for item in items:
if isinstance(item, asyncio.Event):
await item.wait()
else:
yield item.encode()
return httpx.Response(200, content=gen(), headers={"content-type": "text/event-stream"})
class TestContentEvent:
"""增量 chunk 处置位(1.3.8, issue #27/#28): 中段沉默计量与对冲的信号源。"""
async def test_stream_sets_content_event_per_chunk(self):
"""流式每个增量 chunk(内容或思考)置位一次——对冲触发窗的沉默复位信号。"""
def handler(request):
return _sse_stream(
_chunk(reasoning="ponder"),
_chunk(content="hi"),
_chunk(content=" there"),
_chunk(usage=_USAGE),
)
event = _CountingEvent()
result = await _complete(_transport_for(handler), _source(), content_event=event)
assert result.content == "hi there"
assert event.set_count == 3 # 3 个增量 chunk 各置位一次
async def test_reasoning_chunk_counts_as_liveness(self):
"""reasoning_content 增量同样置位并进 chunk 序列(B1: 思考增量是存活证据)。"""
def handler(request):
return _sse_stream(
_chunk(reasoning="step1"),
_chunk(reasoning="step2"),
_chunk(content="ok"),
_chunk(usage=_USAGE),
)
event = _CountingEvent()
result = await _complete(_transport_for(handler), _source(), content_event=event)
assert result.thinking == "step1step2"
assert event.set_count == 3
# 快速投递下零超阈沉默: "观测过且零沉默"(0.0) ≠ "无从观测"(None);
# 两值为 0.0 而非 None 同时证明 chunk 序列 ≥2(思考增量也入了序列)
assert result.stall_ms == 0.0 and result.stall_count == 0.0
async def test_non_stream_never_sets_content_event(self):
"""非流式无中途信号: 给了事件也永不置位;stall 两值恒 None(排除条款)。"""
def handler(request):
return httpx.Response(
200, json={"choices": [{"message": {"content": "42"}}], "usage": _USAGE}
)
event = _CountingEvent()
result = await _complete(
_transport_for(handler), _source(), stream=False, content_event=event
)
assert result.content == "42"
assert event.set_count == 0
assert result.stall_ms is None and result.stall_count is None
async def test_none_content_event_keeps_behavior(self):
"""`None` = 调用方不观测增量 chunk(未启用中段对冲): 行为与旧版逐字相同。"""
def handler(request):
return _sse_stream(_chunk(content="ok"), _chunk(usage=_USAGE))
result = await _complete(_transport_for(handler), _source(), content_event=None)
assert result.content == "ok"
assert result.ttft_ms is not None
async def test_content_event_is_required_keyword(self):
"""端口必填约定同 first_token_event: 漏传必须 TypeError。"""
def handler(request):
return _sse_stream(_chunk(content="ok"), _chunk(usage=_USAGE))
transport = _transport_for(handler)
with pytest.raises(TypeError):
await transport.complete(
messages=[{"role": "user", "content": "hi"}],
source=_source(),
stream=True,
overlay={},
call_id="cid-1",
reasoning_effort=None,
first_token_event=None,
)
class TestStallFromChunkTimes:
"""沉默计量纯函数(设计 §4.2): 严格大于阈值才减、chunk<2 排除、尾部不计。"""
def test_gap_above_threshold_accumulates(self):
stall_ms, stall_count = _stall_from_chunk_times([0.0, 0.3, 2.0, 2.1], 1000)
assert stall_ms == pytest.approx(1700.0) # 仅 0.3→2.0 的 1.7s 段超阈
assert stall_count == 1.0
def test_gap_exactly_at_threshold_not_deducted(self):
"""间隔恰好 == 阈值不减(严格大于,pi-tps 口径)。"""
assert _stall_from_chunk_times([0.0, 1.0], 1000) == (0.0, 0.0)
def test_fewer_than_two_chunks_returns_none(self):
"""0/1 个 chunk → (None, None): 无从观测,不得伪造 0(vLLM 排除条款)。"""
assert _stall_from_chunk_times([], 1000) == (None, None)
assert _stall_from_chunk_times([0.5], 1000) == (None, None)
async def test_tail_not_counted(self):
"""末 chunk → 流结束(usage/[DONE])的间隔不进 stall(尾部归活跃侧,N1)。"""
gate = asyncio.Event()
def handler(request):
return _gated_sse_response(
_chunk(content="a"),
_chunk(content="b"),
gate,
_chunk(usage=_USAGE),
"data: [DONE]\n\n",
)
async def open_after_pause():
await asyncio.sleep(0.2) # 真实尾部停顿 200ms(阈值 50ms 的 4×;方向安全: 只会更大)
gate.set()
task = asyncio.create_task(
_complete(_transport_for(handler), _source(stall_threshold_ms=50), content_event=None)
)
await asyncio.gather(task, open_after_pause())
result = task.result()
assert result.content == "ab"
# 唯一的相邻 chunk 间隔是快速段;200ms 的尾部停顿若被计入即超阈暴露
assert result.stall_ms == 0.0 and result.stall_count == 0.0
async def test_empty_or_usage_frames_not_timed(self):
"""空 chunk/usage 帧/keepalive 不产生时间戳记录点: 三段停顿只算一次 chunk 间隔。"""
gates = [asyncio.Event() for _ in range(3)]
def handler(request):
return _gated_sse_response(
_chunk(content="a"),
gates[0],
'data: {"choices":[{"delta":{}}]}\n\n', # 空 chunk
gates[1],
": ping\n\n", # SSE keepalive
gates[2],
_chunk(usage=_USAGE), # usage-only 帧
_chunk(content="b"),
"data: [DONE]\n\n",
)
async def open_gates():
for gate in gates:
await asyncio.sleep(0.2)
gate.set()
task = asyncio.create_task(
_complete(_transport_for(handler), _source(stall_threshold_ms=50), content_event=None)
)
await asyncio.gather(task, open_gates())
result = task.result()
assert result.content == "ab"
# 三个非增量帧若被计时,三段 200ms 间隔各自超阈(count==3);
# 真相: chunk1→chunk2 仅一个间隔(~600ms),count==1
assert result.stall_count == 1.0
assert result.stall_ms >= 400.0 # 下界证明停顿真实发生(慢机器只会更大,不设上界)
class TestMissingDoneSemantics: class TestMissingDoneSemantics:
def _no_done_handler(self, request): def _no_done_handler(self, request):
return _sse_stream(_chunk(content="partial"), _chunk(usage=_USAGE), done=False) return _sse_stream(_chunk(content="partial"), _chunk(usage=_USAGE), done=False)
@@ -1144,3 +1440,67 @@ async def test_custom_profile_raw_roots_cannot_override_managed_intent(key):
assert sent == [] assert sent == []
finally: finally:
await transport.aclose() await transport.aclose()
class TestRetryAfterNonFinite:
"""F1: `Retry-After` 非有限值必须当作"无提示"(计划 §3.6 / §5 批次 G)。
`float("inf")` 能被 `float()` 成功解析,又能通过既有的 `seconds > 0`——
它会一路变成 `retry_after_s=inf`,而 `backoff_delay` 的 `max(delay, retry_after)`
取大之后就是一次**永不醒来**的退避 sleep(库不夹 `backoff_max_s`)。
"""
def _translate(self, raw, *, name="qwen_1"):
return _translate_429(_source(name=name), "{}", {"retry-after": raw}, {"body_text": "{}"})
@pytest.mark.parametrize("raw", ["inf", "-inf", "1e999", "Infinity"])
def test_non_finite_becomes_no_hint_with_exactly_one_warning(self, raw):
messages: list[str] = []
sink_id = logger.add(messages.append, level="WARNING")
try:
exc = self._translate(raw)
finally:
logger.remove(sink_id)
assert exc.retry_after_s is None
hits = [m for m in messages if "retry_after_not_finite" in m]
assert len(hits) == 1
assert "qwen_1" in hits[0] # 告警要能定位到源
assert raw not in hits[0] # 但不回显原始头字符串(不拼接、不截断)
@pytest.mark.parametrize(
"raw", ["nan", "", " ", "-1", "0", "Wed, 21 Oct 2026 07:28:00 GMT", "soon"]
)
def test_other_unusable_values_stay_silent(self, raw):
"""429 风暴下逐次告警会淹掉真信号: 只有非有限值这一类新增告警。
`nan` 仍走既有的 `seconds > 0` 恒假值语义,本版**不为它新增分支**。
"""
messages: list[str] = []
sink_id = logger.add(messages.append, level="WARNING")
try:
exc = self._translate(raw)
finally:
logger.remove(sink_id)
assert exc.retry_after_s is None
assert [m for m in messages if "retry_after_not_finite" in m] == []
def test_finite_positive_still_reaches_backoff(self):
"""有限正数一字不改地保留,并照常参与 `max(delay, retry_after)` 取大。"""
exc = self._translate("2.5")
assert exc.retry_after_s == 2.5
policy = RetryPolicy(max_attempts=3, backoff_base_s=0.001, backoff_max_s=0.01)
assert backoff_delay(policy, 1, exc, lambda: 0.5) == 2.5
# 而非有限值被吃掉之后,退避退回纯指数,不会变成永不醒来的 sleep
assert backoff_delay(policy, 1, self._translate("inf"), lambda: 0.5) < 1.0
def test_source_name_is_a_required_keyword(self):
"""私有函数的必填 kw: 漏传即 `TypeError`,不给默认值掩盖调用点漏改。"""
with pytest.raises(TypeError):
_parse_retry_after("2.5")
assert _parse_retry_after("2.5", source_name="qwen_1") == 2.5
def test_insufficient_quota_is_still_source_dead(self):
"""分类判据不受本次改动影响(告警只加在 429 限速那一支)。"""
body = json.dumps({"error": {"type": "insufficient_quota"}})
exc = _translate_429(_source(), body, {"retry-after": "inf"}, {"body_text": body})
assert isinstance(exc, SourceDeadError)
+468
View File
@@ -0,0 +1,468 @@
"""协议聚合器的分片、防御与快照契约。"""
import copy
import json
import pytest
from polygateway.errors import TransientError
from polygateway.transports._openai_completion import (
ChatCompletionAccumulator,
parse_chat_completion,
)
def chunk(delta, **extra):
"""构造单 choice 协议帧。"""
return (
"data: "
+ json.dumps({"id": "upstream-1", "choices": [{"index": 0, "delta": delta, **extra}]})
+ "\n\n"
)
def tool_frames():
"""两个交错分片工具,保留空白及 Unicode。"""
return [
chunk({"role": "assistant", "content": None}),
chunk(
{
"tool_calls": [
{
"index": 1,
"id": "b",
"type": "function",
"function": {"name": "echo", "arguments": '{"v":'},
},
{
"index": 0,
"id": "a",
"type": "function",
"function": {"name": "echo", "arguments": "{"},
},
]
}
),
chunk(
{
"tool_calls": [
{"index": 0, "function": {"arguments": '"v": "中" }'}},
{"index": 1, "function": {"arguments": "2}"}},
]
},
finish_reason="tool_calls",
),
]
def accumulate():
"""复用双工具网关帧样本。"""
state = ChatCompletionAccumulator(response_mode="openai")
for frame in tool_frames():
state.add_chunk(json.loads(frame.removeprefix("data: ")))
return state
@pytest.mark.parametrize("role", [None, "user", "tool"])
def test_role_is_required_everywhere(role):
"""缺角色不能被协议成功掩盖。"""
state = accumulate()
state.message.pop("role")
if role:
state.message["role"] = role
with pytest.raises(TransientError, match="role"):
state.finish()
with pytest.raises(TransientError, match="role"):
parse_chat_completion({"choices": [{"message": state.message}]})
@pytest.mark.parametrize(
"change",
[
lambda t: t.pop("id"),
lambda t: t.update(type="custom"),
lambda t: t["function"].pop("name"),
lambda t: t["function"].pop("arguments"),
lambda t: t["function"].update(arguments=12),
],
)
def test_incomplete_tool_is_not_success(change):
"""工具必需字段不能靠正文兜底。"""
state = accumulate()
state.message["content"] = "text"
change(state.tools[0])
with pytest.raises(TransientError):
state.finish()
@pytest.mark.parametrize(
"delta",
[
[],
{"tool_calls": {}},
{"content": []},
{"role": "user"},
{"tool_calls": [{"index": True}]},
{"tool_calls": [{"index": -1}]},
{"tool_calls": [{"index": None}]},
{"tool_calls": [{"index": 0, "function": {"arguments": 12}}]},
],
)
def test_invalid_delta_is_domain_error(delta):
"""畸形输入不泄漏裸类型错误。"""
state = ChatCompletionAccumulator(response_mode="openai")
with pytest.raises(TransientError):
state.add_chunk({"choices": [{"index": 0, "delta": delta}]})
def test_identity_repeat_does_not_refresh_progress():
"""身份只初次生效算进展;同值重复不能续命。"""
state = accumulate()
repeated = {
"choices": [
{"index": 0, "delta": {"tool_calls": [{"index": 0, "id": "a", "type": "function"}]}}
]
}
assert not state.add_chunk(repeated).progress
repeated["choices"][0]["delta"]["tool_calls"][0]["id"] = "other"
with pytest.raises(TransientError, match="冲突"):
state.add_chunk(repeated)
@pytest.mark.parametrize("delta", [{"role": "assistant"}, {}, {"content": ""}])
def test_metadata_not_progress(delta):
"""空帧和角色不算 token。"""
state = ChatCompletionAccumulator(response_mode="openai")
assert not state.add_chunk({"choices": [{"index": 0, "delta": delta}]}).progress
def test_nonstream_snapshot_and_length_preserved():
"""非流式扩展字段与参数原文独立保存,不修复 length 产物。"""
message = accumulate().finish().message
message["extension"] = {"nested": [1]}
message["tool_calls"][0]["function"]["arguments"] = '{"unfinished":'
original = copy.deepcopy(message)
result = parse_chat_completion(
{
"id": "id",
"choices": [{"message": message, "finish_reason": "length"}],
"usage": {"extra": 3},
}
)
message["extension"]["nested"].append(2)
assert result.message == original
assert result.finish_reason == "length" and result.usage == {"extra": 3}
@pytest.mark.parametrize("choices", [[], [{"index": 1, "message": {}}], [{}, {}]])
def test_nonstream_single_choice(choices):
"""不会静默取第一个 choice。"""
with pytest.raises(TransientError):
parse_chat_completion({"choices": choices})
def test_explicit_empty_tools_remains_present():
"""显式空工具列表与缺省不同,不删除已接收字段。"""
state = ChatCompletionAccumulator(response_mode="openai")
state.add_chunk(
{
"choices": [
{"index": 0, "delta": {"role": "assistant", "content": "ok", "tool_calls": []}}
]
}
)
assert state.finish().message["tool_calls"] == []
def test_fragmented_name_and_arguments_with_usage():
"""名称/参数分片逐字拼接,usage 扩展原样快照。"""
state = ChatCompletionAccumulator(response_mode="openai")
state.add_chunk(
{
"id": "response",
"choices": [
{
"index": 0,
"delta": {
"role": "assistant",
"tool_calls": [
{
"index": 3,
"id": "call",
"type": "function",
"function": {"name": "ec", "arguments": " "},
}
],
},
}
],
}
)
progress = state.add_chunk(
{
"choices": [
{
"index": 0,
"delta": {
"content": "text",
"reasoning_content": "reason",
"tool_calls": [
{"index": 3, "function": {"name": "ho", "arguments": "{} "}}
],
},
"finish_reason": "length",
}
]
}
)
assert (
progress.content == "text" and progress.reasoning_content == "reason" and progress.progress
)
usage = {"prompt_tokens": 7, "completion_tokens": 2, "extra": {"nested": 9}}
assert not state.add_chunk({"choices": [], "usage": usage}).progress
result = state.finish()
usage["extra"]["nested"] = 10
assert result.message["tool_calls"][0]["function"] == {"name": "echo", "arguments": " {} "}
assert result.usage["extra"]["nested"] == 9
assert result.finish_reason == "length"
# —— 1.3.10(#30): null 优先、未知字段无损合并、finish/usage 位置 ——
def _feed(state, *chunks):
"""依次喂入已解码帧,返回每帧的 ChatDelta。"""
return [state.add_chunk(c) for c in chunks]
def _frame(delta, finish=None, **top):
"""单 choice 已解码帧。"""
choice = {"index": 0, "delta": delta}
if finish is not None:
choice["finish_reason"] = finish
return {"choices": [choice], **top}
def test_null_known_and_unknown_fields_are_absent():
"""refusal/tool_calls/role/arguments 为 null 不判失败,键按缺省保留。"""
state = ChatCompletionAccumulator(response_mode="openai")
_feed(
state,
_frame({"role": None, "refusal": None, "tool_calls": None, "content": None}),
_frame({"role": "assistant", "content": "hi"}, finish="stop"),
)
message = state.finish().message
assert message["refusal"] is None and message["content"] == "hi"
assert message["role"] == "assistant"
def test_role_only_null_fails_at_finish():
"""全程只有 role:null 即缺 role,照旧失败。"""
state = ChatCompletionAccumulator(response_mode="openai")
_feed(state, _frame({"role": None, "content": "x"}, finish="stop"))
with pytest.raises(TransientError, match="role"):
state.finish()
def test_null_arguments_and_null_tool_elements():
"""arguments:null 视缺省;tool_calls 中 null 元素跳过。"""
state = ChatCompletionAccumulator(response_mode="openai")
tool = {"index": 0, "id": "a", "type": "function", "function": {"name": "f"}}
_feed(
state,
_frame({"role": "assistant", "tool_calls": [None, tool]}),
_frame({"tool_calls": [{"index": 0, "function": {"arguments": None}}]}),
_frame({"tool_calls": [{"index": 0, "function": {"arguments": "{}"}}]}, "tool_calls"),
)
assert state.finish().message["tool_calls"][0]["function"] == {"name": "f", "arguments": "{}"}
def test_null_choices_and_delta_are_absent():
"""chunk 层 choices:null 与 choice 层 delta:null 视缺省。"""
state = ChatCompletionAccumulator(response_mode="openai")
assert not state.add_chunk({"choices": None}).progress
assert not state.add_chunk({"choices": [{"index": 0, "delta": None}]}).progress
def test_reasoning_alias_is_progress_and_preserved():
"""只有 reasoning 字段: 计活性、交别名片段、保真原键保留。"""
for mode in ("openai", "text"):
state = ChatCompletionAccumulator(response_mode=mode)
delta = state.add_chunk(_frame({"role": "assistant", "reasoning": "think"}))
assert delta.progress and delta.reasoning == "think" and delta.reasoning_content == ""
state.add_chunk(_frame({"reasoning": " more"}))
openai = ChatCompletionAccumulator(response_mode="openai")
_feed(openai, _frame({"role": "assistant", "reasoning": "a"}), _frame({"reasoning": "b"}))
assert openai.raw_message()["reasoning"] == "ab"
def _detail(text, **extra):
return {"type": "reasoning.text", "index": 0, "format": "x-v1", "text": text, **extra}
def test_reasoning_details_merge_by_index_without_label_concat():
"""OpenRouter reasoning_details 分片: index 合并,format/id/type 不拼接。"""
state = ChatCompletionAccumulator(response_mode="openai")
_feed(
state,
_frame({"role": "assistant", "reasoning_details": [_detail("he", id="r1")]}),
_frame({"reasoning_details": [_detail("llo", id="r1")]}),
_frame({"content": "ok"}, finish="stop"),
)
details = state.finish().message["reasoning_details"]
assert details == [
{"type": "reasoning.text", "index": 0, "format": "x-v1", "text": "hello", "id": "r1"}
]
assert not state.conflicts
def test_tool_level_nested_extension_preserved():
"""工具层未知嵌套对象(如 thought signature)原样保留以便回传。"""
state = ChatCompletionAccumulator(response_mode="openai")
extra = {"google": {"thought_signature": "sig"}}
tool = {"index": 0, "id": "a", "type": "function", "extra_content": extra}
_feed(
state,
_frame({"role": "assistant", "tool_calls": [{**tool, "function": {"name": "f"}}]}),
_frame({"tool_calls": [{"index": 0, "function": {"arguments": "{}", "strict": True}}]}),
)
snapshot = state.finish().message["tool_calls"][0]
assert snapshot["extra_content"] == extra and snapshot["function"]["strict"] is True
@pytest.mark.parametrize(
("first", "second"),
[(1, 2), (True, False), ("s", {"o": 1}), ([{"index": 0, "t": "a"}], ["plain"])],
)
def test_conflict_drops_key_and_bans_path(first, second):
"""数值/布尔/形态冲突: 删键、登记路径、后续同路径丢弃、调用成功。"""
state = ChatCompletionAccumulator(response_mode="openai")
_feed(
state,
_frame({"role": "assistant", "content": "a", "ext": first}),
_frame({"ext": second}),
_frame({"ext": first}, finish="stop"),
)
assert "ext" not in state.finish().message
assert state.conflicts == frozenset({"delta.ext"})
def test_plain_arrays_append_and_same_scalar_idempotent():
"""普通数组追加;同值标量幂等;追加数组中的字符串算活性。"""
state = ChatCompletionAccumulator(response_mode="openai")
deltas = _feed(
state,
_frame({"role": "assistant", "notes": ["a"], "n": 1}),
_frame({"notes": ["b", None], "n": 1}),
)
message = state.raw_message()
assert message["notes"] == ["a", "b"] and message["n"] == 1
assert all(d.progress for d in deltas[1:])
def test_one_progress_per_frame_with_many_fragments():
"""同帧多个字段只产生一次进展事件(ChatDelta 单值)。"""
state = ChatCompletionAccumulator(response_mode="text")
delta = state.add_chunk(_frame({"content": "a", "reasoning_content": "b", "x": "c"}))
assert delta.progress is True
def test_label_only_unknown_is_not_progress():
"""未知层仅标签键(type/format/id/index)不刷新活性。"""
state = ChatCompletionAccumulator(response_mode="openai")
assert not state.add_chunk(_frame({"meta": {"type": "x", "format": "y"}})).progress
@pytest.mark.parametrize(
("frames", "after"),
[
([_frame({"content": "a"}, usage={"x": 1}), _frame({}, finish="stop")], False),
([_frame({"content": "a"}, finish="stop", usage={"x": 1})], True),
([_frame({"content": "a"}, finish="stop"), {"choices": [], "usage": {"x": 1}}], True),
([_frame({"content": "a"}, finish="stop")], False),
],
)
def test_usage_after_finish_tracking(frames, after):
"""usage 必须在 finish 同帧或之后到达才算流走到自然末尾。"""
for mode in ("openai", "text"):
state = ChatCompletionAccumulator(response_mode=mode)
_feed(state, *frames)
assert state.finish_seen and state.finish_reason == "stop"
assert state.usage_after_finish is after
def test_text_mode_ignores_other_choice_index():
"""文本模式只取 index 缺省或为 0 的 choice,不混入 index=1。"""
state = ChatCompletionAccumulator(response_mode="text")
delta = state.add_chunk(
{
"choices": [
{"index": 1, "delta": {"content": "B"}, "finish_reason": "length"},
{"index": 0, "delta": {"content": "A"}},
]
}
)
assert delta.content == "A" and state.finish_reason is None
def test_text_mode_finish_non_string_ignored():
"""文本模式 finish 非字符串记 None,不判失败。"""
state = ChatCompletionAccumulator(response_mode="text")
state.add_chunk(_frame({"content": "a"}, finish=5))
assert state.finish_reason is None and not state.finish_seen
def test_nonstream_null_tool_elements_stripped():
"""非流式 tool_calls 中 null 元素剔除(回传 [null] 会被上游拒),null 列表原样保留。"""
tool = {"id": "a", "type": "function", "function": {"name": "f", "arguments": "{}"}}
body = {
"choices": [{"message": {"role": "assistant", "tool_calls": [None, tool], "refusal": None}}]
}
assert parse_chat_completion(body).message["tool_calls"] == [tool]
body["choices"][0]["message"]["tool_calls"] = None
assert parse_chat_completion(body).message["tool_calls"] is None
@pytest.mark.parametrize(("index", "taken"), [(None, True), (0, True), (False, False), (1, False)])
def test_text_mode_choice_index_null_is_zero(index, taken):
"""审查 I3: 文本模式 index:null 视缺省(=0);bool 不冒充 0。"""
state = ChatCompletionAccumulator(response_mode="text")
delta = state.add_chunk(
{"choices": [{"index": index, "delta": {"content": "hi"}, "finish_reason": "stop"}]}
)
assert (delta.content == "hi") is taken and (state.finish_reason == "stop") is taken
def test_null_only_tool_elements_do_not_create_empty_list():
"""审查 Minor1: tool_calls null 后 [null] 不得产出会被上游拒收的空数组。"""
state = ChatCompletionAccumulator(response_mode="openai")
_feed(
state,
_frame({"role": "assistant", "content": "a", "tool_calls": None}),
_frame({"tool_calls": [None]}, finish="stop"),
)
assert state.finish().message["tool_calls"] is None
body = {"choices": [{"message": {"role": "assistant", "content": "a", "tool_calls": [None]}}]}
assert parse_chat_completion(body).message["tool_calls"] is None
def test_tool_calls_null_then_list_merges():
"""tool_calls 先 null 后列表: 正常聚合工具。"""
state = ChatCompletionAccumulator(response_mode="openai")
tool = {"index": 0, "id": "a", "type": "function", "function": {"name": "f", "arguments": "{}"}}
_feed(
state,
_frame({"role": "assistant", "tool_calls": None}),
_frame({"tool_calls": [tool]}, finish="tool_calls"),
)
assert state.finish().message["tool_calls"][0]["id"] == "a"
def test_nonstream_role_checked_before_verdict():
"""审查 Minor3: 非流式缺 role 是形态失败,先于结局判定。"""
from polygateway.transports._openai_completion import check_completion_body
with pytest.raises(TransientError, match="role"):
check_completion_body(
{"choices": [{"message": {"content": "a"}, "finish_reason": "length"}]}
)
+376
View File
@@ -0,0 +1,376 @@
"""OpenAI 消息保真回归:按网关 Chat Completions 形态构造。"""
import json
import httpx
import pytest
from polygateway.errors import AllSourcesExhausted, TransientError
from tests.unit.test_client import _client
from tests.unit.test_openai_compat import _complete, _source, _sse_stream, _transport_for
from tests.unit.test_openai_completion import accumulate, chunk, tool_frames
async def test_error_before_done_is_failure():
"""文本模式也不能接受 error 后的 DONE。"""
transport = _transport_for(
lambda _: _sse_stream(chunk({"content": "partial"}), 'data: {"error": null}\n\n')
)
try:
with pytest.raises(TransientError):
await _complete(transport, _source())
finally:
await transport.aclose()
async def test_mixed_content_reasoning_survive():
"""同一帧双文本不互斥。"""
transport = _transport_for(
lambda _: _sse_stream(chunk({"content": "answer", "reasoning_content": "reason"}))
)
try:
result = await _complete(transport, _source())
assert (result.content, result.thinking) == ("answer", "reason")
finally:
await transport.aclose()
async def test_tools_roundtrip_and_retry():
"""真实 transport 经过治理,失败重试不污染工具,消息可直接往返。"""
requests = []
def handler(request):
requests.append(json.loads(request.content))
if len(requests) == 1:
return _sse_stream(
chunk({"content": "partial"}),
'data: {"error": {"code": "upstream_stream_read_error"}}\n\n',
)
return _sse_stream(*tool_frames())
client = _client(handler=handler)
try:
response = await client.chat(
[{"role": "user", "content": "hi"}],
response_mode="openai",
overlay={"reasoning_effort": "max"},
)
assert response.call_stats.attempts == 2
message = response.completion.message
assert message["role"] == "assistant" and message["content"] is None
assert [t["id"] for t in message["tool_calls"]] == ["a", "b"]
assert message["tool_calls"][0]["function"]["arguments"] == '{"v": "中" }'
assert response.completion.response_id == "upstream-1"
assert response.completion.finish_reason == "tool_calls"
messages = [message, {"role": "tool", "tool_call_id": "a", "content": "ok"}]
await client.chat(messages, response_mode="openai")
assert requests[-1]["messages"] == messages
assert requests[0]["reasoning_effort"] == "max"
finally:
await client._transport.aclose()
@pytest.mark.parametrize("role", [None, "user"])
async def test_final_role_required(role):
"""非流式缺失或错误 role 必须协议失败。"""
message = {"content": "ok"}
if role:
message["role"] = role
client = _client(
handler=lambda _: httpx.Response(200, json={"choices": [{"message": message}]})
)
try:
with pytest.raises(AllSourcesExhausted):
await client.chat(
[{"role": "user", "content": "hi"}], stream=False, response_mode="openai"
)
finally:
await client._transport.aclose()
@pytest.mark.parametrize("stream", [True, False])
async def test_completion_cache_roundtrip(stream):
"""完整结果回放,修改返回对象不毒化缓存;模式隔离。"""
from polygateway.backends.memory.cache import InMemoryCache
completion = accumulate().finish()
def handler(_):
if stream:
return _sse_stream(*tool_frames())
return httpx.Response(
200,
json={
"id": "upstream-1",
"choices": [{"message": completion.message, "finish_reason": "tool_calls"}],
},
)
client = _client(
handler=handler, cache=InMemoryCache(), cache_namespace="tools", cache_ttl_s=60
)
try:
first = await client.chat([], stream=stream, response_mode="openai")
second = await client.chat([], stream=stream, response_mode="openai")
assert not first.cache_hit and second.cache_hit
assert second.completion == first.completion
assert second.call_id != first.call_id and second.call_stats.attempts == 0
second.completion.message["tool_calls"].clear()
third = await client.chat([], stream=stream, response_mode="openai")
assert len(third.completion.message["tool_calls"]) == 2
finally:
await client._transport.aclose()
@pytest.mark.parametrize(
"kwargs",
[
{"response_mode": "bad"},
{"response_mode": "openai", "structured": "json"},
{"response_mode": "openai", "overlay": {"n": 2}},
{"response_mode": "openai", "overlay": {"n": True}},
],
)
async def test_invalid_mode_combinations_fail_before_http(kwargs):
"""组合错误入口拒绝。"""
def unexpected(_):
pytest.fail("不应发送 HTTP")
client = _client(handler=unexpected)
try:
with pytest.raises(ValueError):
await client.chat([], **kwargs)
finally:
await client._transport.aclose()
async def test_openai_never_salvages():
"""无 finish 后 usage 的截断流即使收全工具也不打捞(1.3.10: 判据由 DONE 改为完成判定)。"""
transport = _transport_for(lambda _: _sse_stream(*tool_frames(), done=False))
try:
with pytest.raises(TransientError, match="missing_done"):
await transport.complete(
messages=[],
source=_source(missing_done="salvage"),
stream=True,
overlay={},
call_id="c",
reasoning_effort=None,
first_token_event=None,
content_event=None,
response_mode="openai",
)
finally:
await transport.aclose()
async def test_openai_finish_with_trailing_usage_is_complete_without_done():
"""对照: 同一工具流补上 finish 之后的 usage 帧,即使无 DONE 也完整交回(#31)。"""
usage = 'data: {"choices": [], "usage": {"prompt_tokens": 3, "completion_tokens": 4}}\n\n'
transport = _transport_for(lambda _: _sse_stream(*tool_frames(), usage, done=False))
try:
result = await transport.complete(
messages=[],
source=_source(),
stream=True,
overlay={},
call_id="c",
reasoning_effort=None,
first_token_event=None,
content_event=None,
response_mode="openai",
)
finally:
await transport.aclose()
assert result.stream_end == "eof_after_finish"
assert [t["id"] for t in result.completion.message["tool_calls"]] == ["a", "b"]
async def test_tool_progress_sets_events():
"""工具首进展驱动两个事件与时间观测。"""
import asyncio
first, progress = asyncio.Event(), asyncio.Event()
transport = _transport_for(lambda _: _sse_stream(*tool_frames()))
try:
result = await transport.complete(
messages=[],
source=_source(),
stream=True,
overlay={},
call_id="c",
reasoning_effort=None,
first_token_event=first,
content_event=progress,
response_mode="openai",
)
assert first.is_set() and progress.is_set()
assert result.ttft_ms is not None and result.stall_count == 0
finally:
await transport.aclose()
async def test_real_transport_stall_hedge_cancels_loser():
"""真实工具流沉默触发异源对冲,连接关闭且赢家不混入半成品。"""
import asyncio
import time
from polygateway.sources import RoundRobinSelector
from polygateway.types import ChatRequest, _CallContext
from tests.unit.test_retry import _src
from tests.unit.test_stall_hedge import _harness
entered, closed = asyncio.Event(), asyncio.Event()
class Hanging(httpx.AsyncByteStream):
"""工具首帧后阻塞,取消由真实 httpx 流处理。"""
async def __aiter__(self):
yield tool_frames()[0].encode()
yield tool_frames()[1].encode()
entered.set()
await asyncio.Event().wait()
async def aclose(self):
closed.set()
count = 0
def handler(_):
nonlocal count
count += 1
if count == 1:
return httpx.Response(200, stream=Hanging())
return _sse_stream(*tool_frames())
transport = _transport_for(handler)
sources = [_src("s1"), _src("s2")]
mw, limiter, *_ = _harness(
sources, transport, stall_after_s=0.05, selector=RoundRobinSelector()
)
context = _CallContext(now=time.monotonic)
try:
response = await asyncio.wait_for(
mw(ChatRequest(messages=[], response_mode="openai", call_context=context)), 3
)
assert entered.is_set() and closed.is_set()
assert response.source_name == "s2" and len(response.completion.message["tool_calls"]) == 2
assert context.snapshot().hedges == 1 and context.snapshot().attempts == 2
finally:
await transport.aclose()
@pytest.mark.parametrize("stream", [True, False])
async def test_bad_usage_is_domain_failure(stream):
"""坏 usage 不能穿透成裸 AttributeError,包括默认文本模式。"""
transport = _transport_for(
lambda _: (
_sse_stream(chunk({"content": "ok"}), 'data: {"choices": [], "usage": [1]}\n\n')
if stream
else httpx.Response(
200, json={"choices": [{"message": {"content": "ok"}}], "usage": [1]}
)
)
)
try:
with pytest.raises(TransientError):
await _complete(transport, _source(), stream=stream)
finally:
await transport.aclose()
async def test_nonstream_tool_result_has_no_liveness_events():
"""非流式工具不制造流式观测。"""
import asyncio
completion = accumulate().finish()
first, progress = asyncio.Event(), asyncio.Event()
transport = _transport_for(
lambda _: httpx.Response(
200, json={"choices": [{"message": completion.message, "finish_reason": "tool_calls"}]}
)
)
try:
result = await transport.complete(
messages=[],
source=_source(),
stream=False,
overlay={},
call_id="c",
reasoning_effort=None,
first_token_event=first,
content_event=progress,
response_mode="openai",
)
assert result.completion.message == completion.message
assert not first.is_set() and not progress.is_set()
assert result.ttft_ms is None and result.stall_ms is None
finally:
await transport.aclose()
async def test_openai_does_not_bypass_managed_conflict():
"""保真模式不静默撤销源级受管意图。"""
from polygateway.errors import RequestRejectedError
def unexpected(_):
pytest.fail("冲突请求不应发送 HTTP")
transport = _transport_for(unexpected)
try:
with pytest.raises(RequestRejectedError):
await transport.complete(
messages=[],
source=_source(enable_thinking=True),
stream=True,
overlay={"reasoning_effort": "max"},
call_id="c",
reasoning_effort=None,
first_token_event=None,
content_event=None,
response_mode="openai",
)
finally:
await transport.aclose()
async def test_cancel_tool_stream_releases_resources():
"""显式取消真实工具流,连接关闭且配额归还。"""
import asyncio
from polygateway.backends.memory.limiter import InMemoryLimiter
from polygateway.types import GlobalLimits
entered, closed = asyncio.Event(), asyncio.Event()
class Hanging(httpx.AsyncByteStream):
"""可确定取消窗口的 HTTP 流。"""
async def __aiter__(self):
for frame in tool_frames()[:2]:
yield frame.encode()
entered.set()
await asyncio.Event().wait()
async def aclose(self):
closed.set()
source = _source(max_concurrency=1)
limiter = InMemoryLimiter(
scope="llm", sources={source.name: source}, global_limits=GlobalLimits(1, 0, 0)
)
client = _client(
sources=[source], limiter=limiter, handler=lambda _: httpx.Response(200, stream=Hanging())
)
task = asyncio.create_task(client.chat([], response_mode="openai"))
try:
await asyncio.wait_for(entered.wait(), 2)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert closed.is_set()
assert (await limiter.source_stats(source.name)).inflight == 0
finally:
task.cancel()
await client._transport.aclose()
+14
View File
@@ -22,6 +22,8 @@ def test_ocr_public_surface_exported():
"OcrTextResult", "OcrTextResult",
"OcrLayoutResult", "OcrLayoutResult",
"OcrLayoutElement", "OcrLayoutElement",
"OcrDocumentResult",
"OcrDocumentElement",
): ):
assert hasattr(polygateway, name), name assert hasattr(polygateway, name), name
assert name in polygateway.__all__, name assert name in polygateway.__all__, name
@@ -88,3 +90,15 @@ def test_every_promised_export_is_actually_importable():
""" """
missing = [name for name in polygateway.__all__ if not hasattr(polygateway, name)] missing = [name for name in polygateway.__all__ if not hasattr(polygateway, name)]
assert not missing, f"__all__ 承诺了但没绑上的符号: {missing}" assert not missing, f"__all__ 承诺了但没绑上的符号: {missing}"
def test_apache_license_metadata():
"""许可正文和包元数据同时存在,不以版本说明替代许可。"""
import tomllib
from pathlib import Path
root = Path(__file__).resolve().parents[2]
assert "Apache License" in (root / "LICENSE").read_text()
assert "Version 2.0" in (root / "LICENSE").read_text()
project = tomllib.loads((root / "pyproject.toml").read_text())["project"]
assert project["license"] == {"file": "LICENSE"}
+48 -2
View File
@@ -12,6 +12,7 @@ from polygateway.ports import (
GateState, GateState,
GateUpdate, GateUpdate,
Middleware, Middleware,
OcrDocumentTransport,
OcrTransport, OcrTransport,
Permit, Permit,
ProviderGate, ProviderGate,
@@ -72,7 +73,19 @@ class _DummyMw:
class _DummyTransport: class _DummyTransport:
async def complete(self, *, messages, source, stream, overlay, call_id, reasoning_effort): async def complete(
self,
*,
messages,
source,
stream,
overlay,
call_id,
reasoning_effort,
first_token_event,
content_event,
response_mode,
):
raise NotImplementedError raise NotImplementedError
@@ -136,6 +149,9 @@ class _DummyRecorder:
error_body, error_body,
attempts, attempts,
total_latency_ms, total_latency_ms,
stall_ms,
stall_count,
stream_end,
) -> None: ... ) -> None: ...
@@ -178,6 +194,7 @@ class TestReasoningTierIsOnlyOnTheChatPort:
(EmbeddingTransport, "embed"), (EmbeddingTransport, "embed"),
(OcrTransport, "recognize_text"), (OcrTransport, "recognize_text"),
(OcrTransport, "parse_layout"), (OcrTransport, "parse_layout"),
(OcrDocumentTransport, "parse_document"),
], ],
) )
def test_other_transports_have_no_reasoning_tier(self, protocol, method): def test_other_transports_have_no_reasoning_tier(self, protocol, method):
@@ -308,7 +325,7 @@ class TestTelemetryRecorderSignature:
"attempts", "attempts",
"total_latency_ms", "total_latency_ms",
} <= set(params) } <= set(params)
assert len(params) - 1 == 36 # 减掉 self assert len(params) - 1 == 39 # 减掉 self
@pytest.mark.parametrize( @pytest.mark.parametrize(
"name", "name",
@@ -327,6 +344,7 @@ class TestTelemetryRecorderSignature:
"error_body", "error_body",
"attempts", "attempts",
"total_latency_ms", "total_latency_ms",
"stream_end",
], ],
) )
def test_caller_dimensions_have_no_default(self, name): def test_caller_dimensions_have_no_default(self, name):
@@ -356,6 +374,34 @@ class TestOcrPorts:
assert isinstance(GoodClient(), OcrLayoutPort) assert isinstance(GoodClient(), OcrLayoutPort)
assert isinstance(GoodTransport(), OcrTransport) assert isinstance(GoodTransport(), OcrTransport)
def test_document_ports_are_runtime_checkable(self):
from polygateway.ports import OcrDocumentPort, OcrDocumentTransport
class GoodClient:
async def parse_document(self, document): ...
class GoodTransport:
async def parse_document(self, *, document, source, call_id): ...
class Empty: ...
assert isinstance(GoodClient(), OcrDocumentPort)
assert isinstance(GoodTransport(), OcrDocumentTransport)
assert not isinstance(Empty(), OcrDocumentPort)
assert not isinstance(Empty(), OcrDocumentTransport)
def test_ocr_transport_members_are_unchanged(self):
"""新能力走独立 Protocol: 往旧 runtime_checkable Protocol 加成员,会让所有
现有实现(测试替身、下游自带 fake)在 isinstance 上一夜之间不合格。"""
from polygateway.ports import OcrTransport
members = {
name
for name in vars(OcrTransport)
if not name.startswith("_") and callable(getattr(OcrTransport, name))
}
assert members == {"recognize_text", "parse_layout", "check_health"}
def test_missing_method_rejected(self): def test_missing_method_rejected(self):
from polygateway.ports import OcrTransport from polygateway.ports import OcrTransport
+292 -4
View File
@@ -76,18 +76,110 @@ class FakeTransport:
self.script = list(script) self.script = list(script)
self.calls = [] self.calls = []
self.efforts = [] self.efforts = []
# 取消用例的确定性窗口: 进入 hang 分支即置位, 用例据此取消而非 sleep 猜时长
self.entered = asyncio.Event()
async def complete(self, *, messages, source, stream, overlay, call_id, reasoning_effort): async def complete(
self,
*,
messages,
source,
stream,
overlay,
call_id,
reasoning_effort,
first_token_event,
content_event,
response_mode,
):
self.calls.append((source.name, call_id)) self.calls.append((source.name, call_id))
self.efforts.append(reasoning_effort) self.efforts.append(reasoning_effort)
action = self.script.pop(0) action = self.script.pop(0)
if isinstance(action, Exception): if isinstance(action, Exception):
raise action raise action
if action == "hang": if action == "hang":
self.entered.set()
await asyncio.Event().wait() await asyncio.Event().wait()
return action return action
class _GenClockTransport:
"""委托 FakeTransport 的薄包装: 每次调用返回前按脚本推进注入钟(1.3.7 批次 C)。
generation_ms 的口径是"只计 transport 调用本身",故推进必须发生在被包
transport 内部;退避耗时由用例自带的 sleep 闭包推进,与本包装无关。
`stalls`(1.3.8 计划 §3.3): 逐次自控上报的 (stall_ms, stall_count),
fake 自己报数、不经真实计时;None(默认)= 该次调用无沉默观测。
"""
def __init__(self, script, advances, clock, stalls=None):
self._inner = FakeTransport(script)
self._advances = list(advances)
self._clock = clock
self._stalls = list(stalls) if stalls is not None else None
@property
def calls(self):
return self._inner.calls
async def complete(
self,
*,
messages,
source,
stream,
overlay,
call_id,
reasoning_effort,
first_token_event,
content_event,
response_mode,
):
advance = self._advances.pop(0)
stall = self._stalls.pop(0) if self._stalls is not None else None
result = await self._inner.complete(
messages=messages,
source=source,
stream=stream,
overlay=overlay,
call_id=call_id,
reasoning_effort=reasoning_effort,
first_token_event=first_token_event,
content_event=content_event,
response_mode=response_mode,
)
if stall is not None:
result = dataclasses.replace(result, stall_ms=stall[0], stall_count=stall[1])
self._clock.advance(advance)
return result
class HangingGate(InMemoryGate):
"""在指定记账写回处永久挂起的门控: 把"取消落在某个 await 上"变成确定性事件。
只覆盖 `record_success` / `record_failure` 两个写回点, 其余行为沿用真实内存实现。
"""
def __init__(self, *, hang_on, **kwargs):
super().__init__(**kwargs)
self._hang_on = hang_on
self.entered = asyncio.Event()
async def _hang(self):
self.entered.set()
await asyncio.Event().wait()
async def record_success(self, entry, *, count_attempt=True):
if self._hang_on == "success":
await self._hang()
return await super().record_success(entry, count_attempt=count_attempt)
async def record_failure(self, entry, reason, force_open):
if self._hang_on == "failure":
await self._hang()
return await super().record_failure(entry, reason, force_open)
class FakeSleep: class FakeSleep:
"""记录退避时长,立即返回(不真等)。""" """记录退避时长,立即返回(不真等)。"""
@@ -109,6 +201,9 @@ def _harness(
rng=lambda: 0.0, rng=lambda: 0.0,
selector=None, selector=None,
pacer=None, pacer=None,
gate=None,
transport=None,
sleep=None,
): ):
clock = clock or FakeClock() clock = clock or FakeClock()
limiter = InMemoryLimiter( limiter = InMemoryLimiter(
@@ -118,9 +213,9 @@ def _harness(
lease_ttl_s=100.0, lease_ttl_s=100.0,
now=clock, now=clock,
) )
gate = InMemoryGate(config=_BREAKER, now=clock) gate = gate if gate is not None else InMemoryGate(config=_BREAKER, now=clock)
transport = FakeTransport(script) transport = transport if transport is not None else FakeTransport(script)
sleep = FakeSleep() sleep = sleep if sleep is not None else FakeSleep()
mw = RetryMW( mw = RetryMW(
scope="llm", scope="llm",
sources=sources, sources=sources,
@@ -467,6 +562,111 @@ class TestScopeUnavailable:
assert resp.content == "ok" and released["done"] assert resp.content == "ok" and released["done"]
class TestCancellationSettlement:
"""1.3.6 §6.3 结算矩阵: 取消时 `settle()` 的取值只由"该刻库知道什么"决定。
取消窗口一律用真实 `asyncio.Event` 钉死(不用 sleep 撞窗口), 否则红绿都不可信。
"""
async def test_cancel_in_flight_keeps_the_reservation(self):
"""S3: transport 在途被取消 → 端口已开始、用量未知 → 保留预扣(不凭空退款)。"""
mw, limiter, _, transport, *_ = _harness(
[_src("a", max_concurrency=1, tpm=1000, est_tokens=400)], ["hang"]
)
task = asyncio.ensure_future(mw(_REQ))
await transport.entered.wait()
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
stats = await limiter.source_stats("a")
assert stats.tpm_used == 400 # est 保留, 而非退成 0
assert stats.inflight == 0
async def test_cancel_after_usage_known_keeps_real_usage(self):
"""S4: 真实 usage 已算出后被取消 → 结算仍是真实值, 不被 est 覆写。"""
clock = FakeClock()
gate = HangingGate(hang_on="success", config=_BREAKER, now=clock)
mw, limiter, *_ = _harness(
[_src("a", max_concurrency=1, tpm=1000, est_tokens=400)],
[_ok()],
clock=clock,
gate=gate,
)
task = asyncio.ensure_future(mw(_REQ))
await gate.entered.wait()
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert (await limiter.source_stats("a")).tpm_used == 15 # 10+5 实测
async def test_cancel_in_dead_failure_branch_keeps_full_refund(self):
"""S5-dead: 源已判死时的既有 `0` 不得因取消退化成 est(不得继续占额度)。"""
clock = FakeClock()
gate = HangingGate(hang_on="failure", config=_BREAKER, now=clock)
mw, limiter, *_ = _harness(
[_src("a", max_concurrency=1, tpm=1000, est_tokens=400)],
[SourceDeadError("401", source_name="a", status_code=401)],
clock=clock,
gate=gate,
)
task = asyncio.ensure_future(mw(_REQ))
await gate.entered.wait()
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert (await limiter.source_stats("a")).tpm_used == 0
async def test_cancel_in_transient_failure_branch_keeps_the_reservation(self):
"""S5-transient: 瞬时失败的结算决定在首个 await 之前定死, 取消拿到同一个 est。"""
clock = FakeClock()
gate = HangingGate(hang_on="failure", config=_BREAKER, now=clock)
mw, limiter, *_ = _harness(
[_src("a", max_concurrency=1, tpm=1000, est_tokens=400)],
[TransientError("boom", source_name="a", status_code=500)],
clock=clock,
gate=gate,
)
task = asyncio.ensure_future(mw(_REQ))
await gate.entered.wait()
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert (await limiter.source_stats("a")).tpm_used == 400
async def test_unclassified_exception_still_refunds_in_full(self):
"""S8 防越界: 未分类异常(无 except 接住)仍逐字走 1.3.5 的全额退还。"""
mw, limiter, *_ = _harness(
[_src("a", max_concurrency=1, tpm=1000, est_tokens=400)], [RuntimeError("boom")]
)
with pytest.raises(RuntimeError):
await mw(_REQ)
stats = await limiter.source_stats("a")
assert stats.tpm_used == 0 and stats.inflight == 0
async def test_real_zero_usage_success_settles_zero(self):
"""防越界: 真实 usage 恰为 0 是**已知事实**, 不得被当成"未知"改按 est 结算。"""
zero = dataclasses.replace(_ok(), prompt_tokens=0, completion_tokens=0)
mw, limiter, *_ = _harness([_src("a", tpm=1000, est_tokens=400)], [zero])
await mw(_REQ)
assert (await limiter.source_stats("a")).tpm_used == 0
async def test_circuit_open_rejection_settles_zero_end_to_end(self):
"""S1 端到端: 开路拒绝的 pick 预扣后按 0 结算, 不给 tpm_used 增加任何量。"""
clock = FakeClock()
script = [TransientError(str(i)) for i in range(9)]
mw, limiter, *_ = _harness(
[_src("a", max_concurrency=1, tpm=10000, est_tokens=400)],
script,
clock=clock,
max_attempts=99,
)
# 3 次瞬时失败后 a 开路 → 第 4 次 pick 被拒绝
with pytest.raises(CircuitOpenError):
await mw(_REQ)
# 三次瞬时失败各保留 est = 1200; 开路那次 pick 若漏了 settle(0) 会再 +400
assert (await limiter.source_stats("a")).tpm_used == 1200
class TestCancellation: class TestCancellation:
async def test_cancel_mid_flight_releases_permit(self): async def test_cancel_mid_flight_releases_permit(self):
mw, limiter, _, _, _, _ = _harness([_src("a", max_concurrency=1)], ["hang"]) mw, limiter, _, _, _, _ = _harness([_src("a", max_concurrency=1)], ["hang"])
@@ -779,6 +979,94 @@ class TestRateLimitPushback:
assert len(transport.calls) == 3 assert len(transport.calls) == 3
class TestGenerationMs:
"""裸生成时间(1.3.7 H8): 只计 transport 调用本身,不含退避/准入/遥测收尾。"""
def _ctx(self, clock):
from polygateway.types import _CallContext
return _CallContext(now=clock)
async def test_generation_ms_excludes_backoff_and_admission(self):
"""[Transient, ok] 脚本: 退避推进 5s、成功次 transport 推进 0.25s。
generation_ms 恒等于成功次 transport 的 250ms;若口径混入了退避,
它会涨到 5250ms 量级——与 total_latency_ms 的下界断言互为对偶。
推进量取二进制可精确表示值(0.25/5.0): int 截断下 0.2 之类会因浮点
误差落到 199,断言随之抖动(同 test_types 既有用例只用 1.5/2.0 的惯例)。
"""
clock = FakeClock()
async def advancing_sleep(seconds):
clock.advance(seconds)
transport = _GenClockTransport(
[TransientError("boom", source_name="a"), _ok()], [0.0, 0.25], clock
)
mw, *_ = _harness(
[_src("a")],
[],
clock=clock,
transport=transport,
sleep=advancing_sleep,
rng=lambda: 2.0, # backoff = 2.0 * (0.5 + 2.0) = 5.0s
)
ctx = self._ctx(clock)
resp = await mw(dataclasses.replace(_REQ, call_context=ctx))
assert resp.content == "ok" and len(transport.calls) == 2
stats = ctx.snapshot()
assert stats.generation_ms == 250
assert stats.total_latency_ms >= 5250
async def test_generation_ms_zero_hedge_flags_without_hedging(self):
"""无对冲时 hedges/hedge_won 恒 0/False(对冲登记是 T3 的事)。"""
mw, *_ = _harness([_src("a")], [_ok()])
ctx = self._ctx(FakeClock())
await mw(dataclasses.replace(_REQ, call_context=ctx))
stats = ctx.snapshot()
assert stats.hedges == 0 and stats.hedge_won is False
async def test_generation_ms_net_of_stall(self):
"""净口径(1.3.8 设计 §4.2): generation_ms = 墙钟 − Σ(超阈值中段沉默)。
FakeClock 推进 5.0s 墙钟、fake 自报 stall 3200ms/2 段 → 净 1800ms;
归因两值进快照,墙钟还原公式 generation_ms + stall_ms == 5000 成立。
推进量取二进制可精确表示值(同类既有用例惯例,防 int 截断抖动)。
"""
clock = FakeClock()
transport = _GenClockTransport([_ok()], [5.0], clock, stalls=[(3200.0, 2)])
mw, *_ = _harness([_src("a")], [], clock=clock, transport=transport)
ctx = self._ctx(clock)
resp = await mw(dataclasses.replace(_REQ, call_context=ctx))
assert resp.content == "ok"
stats = ctx.snapshot()
assert stats.generation_ms == 1800
assert stats.stall_ms == 3200 and stats.stall_count == 2
assert stats.generation_ms + stats.stall_ms == 5000 # 墙钟还原公式
async def test_generation_ms_clamps_negative_to_zero(self):
"""沉默上报超过墙钟(异常数据/钟偏差防御) → 净口径 clamp 到 0(N4),归因原样保留。"""
clock = FakeClock()
transport = _GenClockTransport([_ok()], [5.0], clock, stalls=[(6000.0, 3)])
mw, *_ = _harness([_src("a")], [], clock=clock, transport=transport)
ctx = self._ctx(clock)
await mw(dataclasses.replace(_REQ, call_context=ctx))
stats = ctx.snapshot()
assert stats.generation_ms == 0
assert stats.stall_ms == 6000 and stats.stall_count == 3
async def test_non_stream_stall_none_no_deduction(self):
"""stall None(非流式/增量 chunk<2,排除条款) → 净口径不退减(=墙钟),归因 None。"""
clock = FakeClock()
transport = _GenClockTransport([_ok()], [5.0], clock) # 不报 stall = 无观测
mw, *_ = _harness([_src("a")], [], clock=clock, transport=transport)
ctx = self._ctx(clock)
await mw(dataclasses.replace(_REQ, call_context=ctx))
stats = ctx.snapshot()
assert stats.generation_ms == 5000
assert stats.stall_ms is None and stats.stall_count is None
class TestLogicalAttemptCounting: class TestLogicalAttemptCounting:
"""尝试登记在 transport 调用**之前**(1.3.5 设计 §4)。 """尝试登记在 transport 调用**之前**(1.3.5 设计 §4)。
+474
View File
@@ -0,0 +1,474 @@
"""中段沉默对冲编排测试(1.3.8 issue #28 设计 §5.2 / 计划批次 D)。
设施纪律(计划 §5): 复用 test_hedge.py 范式——两源 scope、事件驱动假 transport、
**真实 loop 钟**(不注入 FakeClock 驱动触发;注入钟仅用于赢家净口径精确断言,
触发路径结构性不读它)、阈值 0.05s、断言容差 4–10×;取消窗口用 entered Event
栅栏钉死,禁 sleep 撞窗口;不标 slow。
命名区隔(审查 N5): 本文件的 `stall_after_s` 是**对冲触发阈值**(相邻增量 chunk
间隔超过它 → 触发对冲);test_hedge.py:150 的 `stall_window_s` 是 **StallClock
预算**(非生产性等待的调用级上限)——同名词不同概念,勿混。
"""
import asyncio
import dataclasses
import time
import pytest
from polygateway.backends.memory.breaker import InMemoryGate
from polygateway.backends.memory.limiter import InMemoryLimiter
from polygateway.errors import AllSourcesExhausted, TransientError
from polygateway.middleware.retry import RetryMW
from polygateway.sources import SourceCooldownMemo
from polygateway.types import (
BackpressurePolicy,
BreakerConfig,
ChatRequest,
GlobalLimits,
RetryPolicy,
_CallContext,
)
from tests.contracts.conftest import FakeClock
from tests.unit.test_hedge import RecordingEmitter
from tests.unit.test_retry import StaticSelector, _ok, _src
_BREAKER = BreakerConfig(fail_threshold=3, cooldown_s=60.0, probe_ttl_s=120.0)
_NO_GLOBAL = GlobalLimits(max_concurrency=0, rpm=0, tpm=0)
_STALL_AFTER_S = 0.05 # 真实 loop 钟对冲触发阈值(≠ StallClock 预算 stall_window_s,N5)
class StallHedgeTransport:
"""事件驱动假 transport(中段沉默剧本): 逐步骤控制 chunk 事件/挂起/成败。
剧本 = 每源一串**步骤**,每次 `complete` 调用从上次位置继续执行到终态步骤
(`ok`/`fail`)为止;多轮调用(重试)顺序消费后续步骤,耗尽即 AssertionError。
步骤:
("chunk",) — 置 first_token_event 与 content_event(首个增量 chunk)
("chunk_after", delay) — 睡 delay 后只置 content_event(后续增量 chunk)
("hang",) — 置位该源 entered,挂起直到该源 release(或被取消)
("ok", content) — 终态: 立即成功(带已登记的 stall/clock 效果)
("ok_after", delay, content) — 终态: 真实 loop 钟睡 delay 后成功
("fail_after", delay, factory) — 终态: 睡 delay 后抛 `factory()` 新造的异常
("stall", ms, count) — 非终态: 给本剧本终态结果打自报 stall 值(假
transport 自己报数,不经真实计时,计划 §3.3)
("advance", secs) — 非终态: 推进注入钟(仅配 FakeClock 的用例)
"""
def __init__(self, scripts: dict[str, list[tuple]], clock: FakeClock | None = None):
self._scripts = {name: list(steps) for name, steps in scripts.items()}
self._pos = dict.fromkeys(scripts, 0)
self._clock = clock
self.calls: list[str] = []
# 逐次记录收到的 first_token_event/content_event 身份(对冲路恒 None 断言原料)
self.ft_events: list[object] = []
self.ce_events: list[object] = []
self.entered = {name: asyncio.Event() for name in scripts}
self.release = {name: asyncio.Event() for name in scripts}
async def complete(
self,
*,
messages,
source,
stream,
overlay,
call_id,
reasoning_effort,
first_token_event,
content_event,
response_mode,
):
self.calls.append(source.name)
self.ft_events.append(first_token_event)
self.ce_events.append(content_event)
steps = self._scripts[source.name]
stall: tuple[float, float] | None = None
while self._pos[source.name] < len(steps):
action = steps[self._pos[source.name]]
self._pos[source.name] += 1
kind = action[0]
if kind == "chunk":
if first_token_event is not None:
first_token_event.set()
if content_event is not None:
content_event.set()
elif kind == "chunk_after":
await asyncio.sleep(action[1])
if content_event is not None:
content_event.set()
elif kind == "hang":
self.entered[source.name].set()
await self.release[source.name].wait()
elif kind == "stall":
stall = (action[1], action[2])
elif kind == "advance":
assert self._clock is not None, "advance 步骤需要构造时注入 FakeClock"
self._clock.advance(action[1])
elif kind == "ok":
result = _ok(action[1])
if stall is not None:
result = dataclasses.replace(result, stall_ms=stall[0], stall_count=stall[1])
return result
elif kind == "ok_after":
await asyncio.sleep(action[1])
result = _ok(action[2])
if stall is not None:
result = dataclasses.replace(result, stall_ms=stall[0], stall_count=stall[1])
return result
elif kind == "fail_after":
await asyncio.sleep(action[1])
raise action[2]()
else:
raise AssertionError(f"未知剧本步骤: {action!r}")
raise AssertionError(f"{source.name} 剧本耗尽且无终态步骤")
def _harness(
sources,
transport,
*,
hedge_after_s=None,
stall_after_s=None,
max_attempts=3,
emitter=None,
selector=None,
limiter=None,
gate=None,
stall_window_s=300.0,
now=None,
):
"""真实 loop 钟装配(同 test_hedge.py 范式;`now` 仅供净口径精确断言注入 FakeClock)。
`stall_window_s` 是 StallClock 预算(N5 命名区隔),与 `stall_after_s` 无关。
"""
limiter = limiter or InMemoryLimiter(
scope="llm",
sources={s.name: s for s in sources},
global_limits=_NO_GLOBAL,
lease_ttl_s=100.0,
)
gate = gate or InMemoryGate(config=_BREAKER)
mw = RetryMW(
scope="llm",
sources=sources,
# 固定配置序: 原路恒为 s1、对冲路恒为 s2,断言不依赖选源器内部状态
selector=selector if selector is not None else StaticSelector(),
limiter=limiter,
gate=gate,
transport=transport,
retry=RetryPolicy(max_attempts=max_attempts, backoff_base_s=0.01, backoff_max_s=0.05),
backpressure=BackpressurePolicy(stall_window_s=stall_window_s, poll_interval_s=0.01),
quota_full="wait",
cooldown_memo=SourceCooldownMemo(),
emitter=emitter,
hedge_after_s=hedge_after_s,
stall_after_s=stall_after_s,
**({"now": now} if now is not None else {}),
)
return mw, limiter, gate
def _req(*, stream=True, ctx=None):
"""中段沉默是流式概念,本文件默认 stream=True(test_hedge.py 默认 False 相反)。"""
return ChatRequest(
messages=[{"role": "user", "content": "hi"}], stream=stream, call_context=ctx
)
def _ctx(now=None):
return _CallContext(now=now if now is not None else time.monotonic)
class TestStallHedgeTrigger:
"""阶段 2 触发(设计 §5.2): 首 token 已至后相邻增量 chunk 间隔超阈值 → 对冲。"""
async def test_stall_triggers_hedge_after_first_token(self):
"""s1 首 chunk 后沉默、s2 即时成功: stall 窗触发对冲,赢家为对冲路。
净口径顺延断言(计划批次 D 首行): 注入 FakeClock + s2 自报 stall
(1500ms/2 段)、advance 2.0s → 赢家 `generation_ms == 500` 精确值,
`generation_ms + stall_ms == total_latency_ms`(同钟还原公式)。
遥测行(T3 缺口补齐钉): 赢家行 stall 两值为自报值,输家行恒 None。
"""
clock = FakeClock()
emitter = RecordingEmitter()
s1, s2 = _src("s1"), _src("s2")
transport = StallHedgeTransport(
{
"s1": [("chunk",), ("hang",), ("ok", "late")],
"s2": [("stall", 1500.0, 2), ("advance", 2.0), ("ok", "fast")],
},
clock=clock,
)
mw, _, _ = _harness(
[s1, s2], transport, stall_after_s=_STALL_AFTER_S, emitter=emitter, now=clock
)
ctx = _ctx(now=clock)
started = time.monotonic()
resp = await asyncio.wait_for(mw(_req(ctx=ctx)), timeout=5)
elapsed = time.monotonic() - started
assert resp.content == "fast" and resp.source_name == "s2"
assert elapsed < 10 * _STALL_AFTER_S # 沉默路被对冲截断,而非等到看门狗
assert transport.calls == ["s1", "s2"]
stats = ctx.snapshot()
assert stats.hedges == 1 and stats.hedge_won is True and stats.attempts == 2
# 赢家路净口径: 墙钟 2000(advance 精确推进)− 沉默 1500 = 500
assert stats.generation_ms == 500
assert stats.stall_ms == 1500 and stats.stall_count == 2
assert stats.generation_ms + stats.stall_ms == stats.total_latency_ms # 还原公式
# 逐次遥测: 赢家行带 stall 归因,被取消输家行恒 None(设计 §11-B2)
winner = next(r for r in emitter.rows if r["source"] == "s2")
loser = next(r for r in emitter.rows if r["source"] == "s1")
assert winner["stall_ms"] == 1500 and winner["stall_count"] == 2
assert winner["error"] is None
assert loser["error"] == "hedge_cancelled"
assert loser["stall_ms"] is None and loser["stall_count"] is None
async def test_single_leg_attempt_row_carries_stall(self):
"""T3 缺口补齐钉: 未启用对冲的 chat 成功行也须经 `_emit` 落 stall 两值。
计划 §3.4 指定 `retry._emit` 显式转发(归 T3);批次 C 落行断言只覆盖了
emitter 直调,chat 路径漏传无人把守——本用例钉住端到端路径。
"""
clock = FakeClock()
emitter = RecordingEmitter()
transport = StallHedgeTransport(
{"s1": [("stall", 300.0, 1), ("advance", 1.0), ("ok", "x")]}, clock=clock
)
mw, _, _ = _harness([_src("s1")], transport, emitter=emitter, now=clock)
ctx = _ctx(now=clock)
resp = await asyncio.wait_for(mw(_req(ctx=ctx)), timeout=5)
assert resp.content == "x"
assert len(emitter.rows) == 1
row = emitter.rows[0]
assert row["error"] is None
assert row["stall_ms"] == 300 and row["stall_count"] == 1
stats = ctx.snapshot()
assert stats.generation_ms == 700 and stats.stall_ms == 300
async def test_stall_recheck_prevents_false_trigger(self):
"""chunk 在超时到期前一刻到达(0.049s 置位,阈值 0.05s)→ 不触发(N3① 复检)。
只断"不触发 + attempts==1"结果,不断精确时序(计划 §7 抖动纪律)。
"""
s1, s2 = _src("s1"), _src("s2")
transport = StallHedgeTransport(
{
"s1": [("chunk",), ("chunk_after", 0.049), ("ok", "done")],
"s2": [("ok", "other")],
}
)
mw, _, _ = _harness([s1, s2], transport, stall_after_s=_STALL_AFTER_S)
ctx = _ctx()
resp = await asyncio.wait_for(mw(_req(ctx=ctx)), timeout=5)
assert resp.content == "done" and resp.source_name == "s1"
assert transport.calls == ["s1"] # 对冲从未发出
stats = ctx.snapshot()
assert stats.hedges == 0 and stats.attempts == 1 and stats.hedge_won is False
async def test_stall_and_after_s_independent_matrix(self):
"""参数化四格(设计 §5.2 逐字): 两阈值独立生效,各格触发语义不串。"""
# 格 1: 仅 AFTER_S + 流式 pre-TTFT 挂起 → AFTER_S 触发(1.3.7 语义不变)
s1, s2 = _src("s1"), _src("s2")
transport = StallHedgeTransport({"s1": [("hang",), ("ok", "late")], "s2": [("ok", "fast")]})
mw, _, _ = _harness([s1, s2], transport, hedge_after_s=_STALL_AFTER_S)
ctx = _ctx()
resp = await asyncio.wait_for(mw(_req(ctx=ctx)), timeout=5)
assert resp.source_name == "s2"
assert ctx.snapshot().hedges == 1
# 格 2: 仅 STALL_AFTER_S + 流式 pre-TTFT 沉默 → **不触发**(阶段 1 不开窗)
s1, s2 = _src("s1"), _src("s2")
transport = StallHedgeTransport(
{"s1": [("hang",), ("ok", "s1ok")], "s2": [("ok", "other")]}
)
mw, _, _ = _harness([s1, s2], transport, stall_after_s=_STALL_AFTER_S)
ctx = _ctx()
task = asyncio.ensure_future(mw(_req(ctx=ctx)))
await transport.entered["s1"].wait()
await asyncio.sleep(4 * _STALL_AFTER_S) # 4× 余量确认 stall 窗管不到 pre-TTFT
assert transport.calls == ["s1"]
transport.release["s1"].set()
resp = await asyncio.wait_for(task, timeout=5)
assert resp.source_name == "s1"
stats = ctx.snapshot()
assert stats.hedges == 0 and stats.attempts == 1
# 格 3: 两者皆设 + 首 chunk 后沉默 → 阶段 2 stall 窗触发
s1, s2 = _src("s1"), _src("s2")
transport = StallHedgeTransport(
{"s1": [("chunk",), ("hang",), ("ok", "late")], "s2": [("ok", "fast")]}
)
mw, _, _ = _harness(
[s1, s2], transport, hedge_after_s=_STALL_AFTER_S, stall_after_s=_STALL_AFTER_S
)
ctx = _ctx()
resp = await asyncio.wait_for(mw(_req(ctx=ctx)), timeout=5)
assert resp.source_name == "s2"
assert ctx.snapshot().hedges == 1
# 格 3b: 两者皆设 + pre-TTFT 挂起 → 阶段 1 AFTER_S 触发(两阶段各管一段)
s1, s2 = _src("s1"), _src("s2")
transport = StallHedgeTransport({"s1": [("hang",), ("ok", "late")], "s2": [("ok", "fast")]})
mw, _, _ = _harness(
[s1, s2], transport, hedge_after_s=_STALL_AFTER_S, stall_after_s=_STALL_AFTER_S
)
ctx = _ctx()
resp = await asyncio.wait_for(mw(_req(ctx=ctx)), timeout=5)
assert resp.source_name == "s2"
assert ctx.snapshot().hedges == 1
# 格 4: 皆无 → 逐字单路(默认关闭回归门的单元格形态)
s1, s2 = _src("s1"), _src("s2")
transport = StallHedgeTransport({"s1": [("hang",), ("ok", "solo")]})
mw, _, _ = _harness([s1, s2], transport)
ctx = _ctx()
task = asyncio.ensure_future(mw(_req(ctx=ctx)))
await transport.entered["s1"].wait()
transport.release["s1"].set()
resp = await asyncio.wait_for(task, timeout=5)
assert resp.content == "solo" and resp.source_name == "s1"
stats = ctx.snapshot()
assert stats.hedges == 0 and stats.attempts == 1
assert transport.ce_events == [None] # 未配置 = 不观测增量 chunk
async def test_stall_only_non_stream_degrades_to_single_leg(self):
"""仅 stall 已设 + `stream=False` → 逐字单路(中段沉默无非流式概念,入口门)。"""
s1, s2 = _src("s1"), _src("s2")
transport = StallHedgeTransport({"s1": [("hang",), ("ok", "x")], "s2": [("ok", "other")]})
mw, _, _ = _harness([s1, s2], transport, stall_after_s=_STALL_AFTER_S)
ctx = _ctx()
task = asyncio.ensure_future(mw(_req(stream=False, ctx=ctx)))
await transport.entered["s1"].wait()
await asyncio.sleep(4 * _STALL_AFTER_S) # 阈值窗已过,仍不得有第二请求
assert transport.calls == ["s1"]
transport.release["s1"].set()
resp = await asyncio.wait_for(task, timeout=5)
assert resp.source_name == "s1"
stats = ctx.snapshot()
assert stats.hedges == 0 and stats.attempts == 1
assert transport.ce_events == [None] # 非流式不建 content_event
class TestStallHedgeWatchdogAndFailure:
"""与看门狗的交互(设计 §5.2): 主路被掐死 ≠ 本轮失败,副路已在跑则等副路。"""
async def test_watchdog_kills_primary_hedge_leg_survives(self):
"""主路首 chunk 后被看门狗掐死(Transient)、副路在途 → 副路结果裁定。
attempt 级记账照常(N9): 主路源熔断失败计数 +1、遥测主路 error 行
(TransientError 对象)+ 副路正常行;"先了结的是失败则等另一路"逐字复用。
"""
emitter = RecordingEmitter()
gate = InMemoryGate(config=_BREAKER)
s1, s2 = _src("s1"), _src("s2")
transport = StallHedgeTransport(
{
"s1": [
("chunk",),
(
"fail_after",
0.12,
lambda: TransientError("inter_token timeout", source_name="s1"),
),
],
"s2": [("ok_after", 0.2, "rescued")],
}
)
mw, _, _ = _harness(
[s1, s2], transport, stall_after_s=_STALL_AFTER_S, emitter=emitter, gate=gate
)
ctx = _ctx()
resp = await asyncio.wait_for(mw(_req(ctx=ctx)), timeout=5)
assert resp.content == "rescued" and resp.source_name == "s2"
stats = ctx.snapshot()
assert stats.hedges == 1 and stats.hedge_won is True
# 掐死是 attempt 级失败: 熔断失败样本 +1(对冲输家取消不喂,此处是喂的——N9 不对称)
gate_s1 = gate._gates["s1"]
assert gate_s1.a0 + gate_s1.a1 == 1
# 遥测: 主路 error 行携 TransientError 对象(掐死),副路正常行
primary_row = next(r for r in emitter.rows if r["source"] == "s1")
hedge_row = next(r for r in emitter.rows if r["source"] == "s2")
assert isinstance(primary_row["error"], TransientError)
assert hedge_row["error"] is None
async def test_both_fail_counts_budget_once(self):
"""主路看门狗掐死 + 副路 Transient: 两败只计一次预算(_combine_failures 复用)。
max_attempts=2 时恰进第二轮: 若两败计两次,第一轮即耗尽,第二轮的两路
调用根本不会发生。两败轮次无赢家 → generation 不记、stall 快照恒 None。
"""
s1, s2 = _src("s1"), _src("s2")
transport = StallHedgeTransport(
{
"s1": [
("chunk",),
("fail_after", 0.1, lambda: TransientError("wd", source_name="s1")),
("chunk",),
("fail_after", 0.1, lambda: TransientError("wd", source_name="s1")),
],
"s2": [
("fail_after", 0.12, lambda: TransientError("h", source_name="s2")),
("fail_after", 0.12, lambda: TransientError("h", source_name="s2")),
],
}
)
mw, _, _ = _harness([s1, s2], transport, stall_after_s=_STALL_AFTER_S, max_attempts=2)
ctx = _ctx()
with pytest.raises(AllSourcesExhausted) as ei:
await asyncio.wait_for(mw(_req(ctx=ctx)), timeout=5)
assert ei.value.reason == "retry_exhausted"
assert transport.calls == ["s1", "s2", "s1", "s2"] # 两败计一次才有第二轮
stats = ctx.snapshot()
assert stats.hedges == 2 # 两败轮次路数照登(实际并发发出即计)
assert stats.hedge_won is False
assert stats.generation_ms == 0 # 无赢家: 裸生成时间无归属不记
assert stats.stall_ms is None and stats.stall_count is None
class TestStallHedgeCancellation:
"""取消穿透(计划取消铁律): stall 等待中外部取消,waiter 收口无残留。"""
async def test_external_cancel_during_stall_wait_penetrates(self):
"""阶段 2 循环内外部 `task.cancel()`: CancelledError 穿透、无残留任务。
阈值取 5.0s 宽窗: 取消确定落在 stall 等待内(阶段 2 循环),不依赖时序。
对冲尚未发出(宽窗未到期)→ 只有原路被取消,permit 释放。
"""
before = asyncio.all_tasks()
emitter = RecordingEmitter()
s1, s2 = _src("s1"), _src("s2")
transport = StallHedgeTransport(
{"s1": [("chunk",), ("hang",), ("ok", "x")], "s2": [("ok", "y")]}
)
mw, limiter, _ = _harness([s1, s2], transport, stall_after_s=5.0, emitter=emitter)
task = asyncio.ensure_future(mw(_req(ctx=_ctx())))
await transport.entered["s1"].wait() # 原路已交付首 chunk 并挂起: 编排处于阶段 2
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert transport.calls == ["s1"] # 宽窗未到期,对冲从未发出
assert {r["source"]: r["error"] for r in emitter.rows} == {"s1": "cancelled"}
assert (await limiter.source_stats("s1")).inflight == 0
assert asyncio.all_tasks() == before # waiter/原路全收口,无残留
class TestStallHedgeEventDiscipline:
"""Event 并发纪律(N3③): 对冲路 content_event 恒 None(v1 不梯次)。"""
async def test_hedge_leg_content_event_always_none(self):
"""原路(流式 + stall 已设)收真事件,对冲路恒 None——逐次身份记录断言。"""
s1, s2 = _src("s1"), _src("s2")
transport = StallHedgeTransport(
{"s1": [("chunk",), ("hang",), ("ok", "late")], "s2": [("ok", "win")]}
)
mw, _, _ = _harness([s1, s2], transport, stall_after_s=_STALL_AFTER_S)
ctx = _ctx()
resp = await asyncio.wait_for(mw(_req(ctx=ctx)), timeout=5)
assert resp.source_name == "s2"
assert transport.calls == ["s1", "s2"]
# 原路携带首 token + content 双观测;对冲路两事件恒 None(N3③,同 H5 首 token 约定)
assert [e is not None for e in transport.ft_events] == [True, False]
assert [e is not None for e in transport.ce_events] == [True, False]
+273 -15
View File
@@ -37,6 +37,8 @@ from polygateway.types import (
EmbeddingTransportResult, EmbeddingTransportResult,
GlobalLimits, GlobalLimits,
LLMResponse, LLMResponse,
OcrDocumentElement,
OcrDocumentTransportResult,
OcrTextTransportResult, OcrTextTransportResult,
RetryPolicy, RetryPolicy,
SourceConfig, SourceConfig,
@@ -89,6 +91,11 @@ _EXPECTED_COLUMNS = [
"error_body", "error_body",
"attempts", "attempts",
"total_latency_ms", "total_latency_ms",
# —— 1.3.8 沉默归因两列(issue #27)——
"stall_ms",
"stall_count",
# —— 1.3.10 结束方式(#31)——
"stream_end",
] ]
# 1.3.5 之前的 26 个 INSERT 字段(旧表形态),供冻结 INSERT 语句与旧表补列用例复用 # 1.3.5 之前的 26 个 INSERT 字段(旧表形态),供冻结 INSERT 语句与旧表补列用例复用
@@ -181,6 +188,11 @@ async def _record_minimal(recorder, call_id="c1", **overrides):
# 逻辑快照两列只属终态行 # 逻辑快照两列只属终态行
"attempts": None, "attempts": None,
"total_latency_ms": None, "total_latency_ms": None,
# —— 1.3.8 沉默归因两列: 默认 None = 非流式/无沉默观测(合法事实)——
"stall_ms": None,
"stall_count": None,
# —— 1.3.10 结束方式: 默认 None = 非流式/缓存命中/失败行 ——
"stream_end": None,
} }
fields.update(overrides) fields.update(overrides)
await recorder.record_llm_call(**fields) await recorder.record_llm_call(**fields)
@@ -262,7 +274,7 @@ class TestSchemaModule:
# COLUMNS 是 INSERT 字段序,不含数据库自填的 created_at # COLUMNS 是 INSERT 字段序,不含数据库自填的 created_at
assert list(COLUMNS) == [c for c in _EXPECTED_COLUMNS if c != "created_at"] assert list(COLUMNS) == [c for c in _EXPECTED_COLUMNS if c != "created_at"]
assert len(COLUMNS) == 36 assert len(COLUMNS) == 39
# 两端 DDL 的列出现顺序 == 物理列序(created_at 在第 19 位) # 两端 DDL 的列出现顺序 == 物理列序(created_at 在第 19 位)
for ddl in (SQLITE_DDL, PG_DDL): for ddl in (SQLITE_DDL, PG_DDL):
assert _first_occurrence_order(ddl, _EXPECTED_COLUMNS) == _EXPECTED_COLUMNS assert _first_occurrence_order(ddl, _EXPECTED_COLUMNS) == _EXPECTED_COLUMNS
@@ -276,8 +288,8 @@ class TestSchemaModule:
"ALTER TABLE llm_calls ADD COLUMN cached_prompt_tokens INTEGER", "ALTER TABLE llm_calls ADD COLUMN cached_prompt_tokens INTEGER",
) )
assert PG_BACKFILL[-1] == ( assert PG_BACKFILL[-1] == (
"total_latency_ms", "stream_end",
"ALTER TABLE llm_calls ADD COLUMN total_latency_ms INTEGER", "ALTER TABLE llm_calls ADD COLUMN stream_end TEXT",
) )
assert all("IF NOT EXISTS" not in stmt for _, stmt in PG_BACKFILL) assert all("IF NOT EXISTS" not in stmt for _, stmt in PG_BACKFILL)
@@ -291,10 +303,10 @@ class TestSchemaModule:
assert insert_sql("sqlite", _PRE_135_COLUMNS) == _FROZEN_SQLITE_INSERT assert insert_sql("sqlite", _PRE_135_COLUMNS) == _FROZEN_SQLITE_INSERT
assert insert_sql("postgres", _PRE_135_COLUMNS) == _FROZEN_PG_INSERT assert insert_sql("postgres", _PRE_135_COLUMNS) == _FROZEN_PG_INSERT
# 全量 36 列: 占位符随列数增长,且不留空洞 # 全量 39 列: 占位符随列数增长,且不留空洞
assert insert_sql("postgres", COLUMNS).count("$") == 36 assert insert_sql("postgres", COLUMNS).count("$") == 39
assert "$36)" in insert_sql("postgres", COLUMNS) assert "$39)" in insert_sql("postgres", COLUMNS)
assert insert_sql("sqlite", COLUMNS).count("?") == 36 assert insert_sql("sqlite", COLUMNS).count("?") == 39
# 裁剪列表按位置占位符重新编号,不留空洞 # 裁剪列表按位置占位符重新编号,不留空洞
assert insert_sql("postgres", ["call_id", "model"]) == ( assert insert_sql("postgres", ["call_id", "model"]) == (
"INSERT INTO llm_calls (call_id, model) VALUES ($1, $2) ON CONFLICT DO NOTHING" "INSERT INTO llm_calls (call_id, model) VALUES ($1, $2) ON CONFLICT DO NOTHING"
@@ -371,7 +383,131 @@ class TestBackendColumnParity:
"""新列只能追加在末尾: 旧表经 ALTER 补列必落末尾,插在中间会让两条路径分叉。""" """新列只能追加在末尾: 旧表经 ALTER 补列必落末尾,插在中间会让两条路径分叉。"""
from polygateway.telemetry.schema import COLUMNS from polygateway.telemetry.schema import COLUMNS
assert COLUMNS[-4:] == ("cause_type", "error_body", "attempts", "total_latency_ms") assert COLUMNS[-7:] == (
"cause_type",
"error_body",
"attempts",
"total_latency_ms",
"stall_ms",
"stall_count",
"stream_end",
)
# 1.3.7(1.3.8 补列前)的表形态: 36 个 INSERT 字段 + created_at = 37 物理列。
# 冻结字面量而非由常量切片派生: 后者会随 schema 改动静默漂移到另一张表上
# (与 _PRE_TENANT_DDL 同款纪律)。
_PRE_138_DDL = """
CREATE TABLE IF NOT EXISTS llm_calls (
call_id TEXT PRIMARY KEY,
parent_call_id TEXT,
session_id TEXT,
model TEXT NOT NULL,
provider TEXT NOT NULL,
source_name TEXT NOT NULL,
messages TEXT NOT NULL,
response TEXT NOT NULL,
thinking TEXT NOT NULL DEFAULT '',
prompt_tokens INTEGER NOT NULL,
completion_tokens INTEGER NOT NULL,
usage_source TEXT NOT NULL,
latency_ms INTEGER NOT NULL,
ttft_ms REAL,
max_inter_token_ms REAL,
cache_hit INTEGER NOT NULL DEFAULT 0,
error TEXT,
cost REAL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
cached_prompt_tokens INTEGER,
model_reported TEXT,
sampling TEXT,
reasoning_tokens INTEGER,
tenant_id TEXT NOT NULL DEFAULT '',
meta TEXT NOT NULL DEFAULT '{}',
thinking_observation TEXT,
reasoning_effort TEXT,
scope TEXT,
operation TEXT,
logical_call_id TEXT,
event_kind TEXT,
http_status_code INTEGER,
error_type TEXT,
cause_type TEXT,
error_body TEXT,
attempts INTEGER,
total_latency_ms INTEGER
);
"""
class TestStallColumns:
"""1.3.8 沉默归因两列(issue #27): 落行、缺省 NULL、迁移幂等与 manual 告警。
新列可空、无默认值、追加末尾——旧行的 NULL 表达"补列之前根本没记过这件事"。
"""
async def test_stall_values_round_trip_and_default_is_null(self, tmp_path):
"""stall 值原样落行读回;未观测行(默认 None)两列是 NULL 而非 0。"""
db = tmp_path / "stall.db"
recorder = SQLiteRecorder(db, auto_migrate=True)
await _record_minimal(recorder, call_id="c-stall", stall_ms=3200, stall_count=2)
await _record_minimal(recorder, call_id="c-plain")
recorder.close()
conn = sqlite3.connect(db)
assert conn.execute(
"SELECT stall_ms, stall_count FROM llm_calls WHERE call_id = 'c-stall'"
).fetchone() == (3200, 2)
# "观测过且零沉默"(0)与"无从观测"(NULL)是两回事: 未传值的行必须是 NULL
assert conn.execute(
"SELECT stall_ms, stall_count FROM llm_calls WHERE call_id = 'c-plain'"
).fetchone() == (None, None)
conn.close()
async def test_auto_migrate_backfills_the_two_columns_idempotently(self, tmp_path):
"""1.3.7 形态旧表(37 物理列)经 auto 补两列;二次初始化幂等不炸。"""
db = tmp_path / "pre138.db"
conn = sqlite3.connect(db)
conn.execute(_PRE_138_DDL)
conn.commit()
conn.close()
recorder = SQLiteRecorder(db, auto_migrate=True)
await _record_minimal(recorder, call_id="c-new", stall_ms=7, stall_count=1)
recorder.close()
# 二次初始化: 探测到列已齐,一条 ALTER 都不发(幂等)
recorder2 = SQLiteRecorder(db, auto_migrate=True)
await _record_minimal(recorder2, call_id="c-new2")
recorder2.close()
conn = sqlite3.connect(db)
assert [r[1] for r in conn.execute("PRAGMA table_info(llm_calls)")] == _EXPECTED_COLUMNS
assert conn.execute(
"SELECT stall_ms, stall_count FROM llm_calls WHERE call_id = 'c-new'"
).fetchone() == (7, 1)
conn.close()
async def test_manual_mode_warning_names_both_new_alters(self, tmp_path, captured_warnings):
"""manual 档缺列告警逐字含两条新 ALTER,物理列原封不动。"""
db = tmp_path / "manual138.db"
conn = sqlite3.connect(db)
conn.execute(_PRE_138_DDL)
conn.commit()
conn.close()
recorder = SQLiteRecorder(db, auto_migrate=False)
await _record_minimal(recorder, call_id="c-manual", stall_ms=9, stall_count=3)
recorder.close()
conn = sqlite3.connect(db)
assert len([r[1] for r in conn.execute("PRAGMA table_info(llm_calls)")]) == 37
conn.close()
assert len(captured_warnings) == 1
message = captured_warnings[0]
assert "stall_ms" in message and "stall_count" in message
assert "ALTER TABLE llm_calls ADD COLUMN stall_ms INTEGER" in message
assert "ALTER TABLE llm_calls ADD COLUMN stall_count INTEGER" in message
class TestSQLiteRecorder: class TestSQLiteRecorder:
@@ -647,7 +783,7 @@ class TestSQLiteCallerDimensionsAcceptance:
conn = sqlite3.connect(db) conn = sqlite3.connect(db)
cols = [r[1] for r in conn.execute("PRAGMA table_info(llm_calls)")] cols = [r[1] for r in conn.execute("PRAGMA table_info(llm_calls)")]
assert cols == _EXPECTED_COLUMNS # 22 → 36 个 recorder 字段(+ created_at 共 37 物理列) assert cols == _EXPECTED_COLUMNS # 22 → 39 个 recorder 字段(+ created_at 共 40 物理列)
rows = dict(conn.execute("SELECT call_id, tenant_id FROM llm_calls").fetchall()) rows = dict(conn.execute("SELECT call_id, tenant_id FROM llm_calls").fetchall())
assert rows["new-row"] == "tenant-a" assert rows["new-row"] == "tenant-a"
assert rows["old-row"] == "" # 不是 None: NULL 会被 RLS 静默吞掉 assert rows["old-row"] == "" # 不是 None: NULL 会被 RLS 静默吞掉
@@ -697,7 +833,7 @@ class TestSQLiteSchemaMode:
"""issue #13: `auto_migrate` 两档——auto 保持自动补列,manual 只裁剪写入不发 DDL。 """issue #13: `auto_migrate` 两档——auto 保持自动补列,manual 只裁剪写入不发 DDL。
列数断言一律按**物理列数**写: 旧表 22 个 INSERT 字段 + `created_at` = 23, 列数断言一律按**物理列数**写: 旧表 22 个 INSERT 字段 + `created_at` = 23,
补齐后 36 + `created_at` = 37。混用 INSERT 字段数与物理列数是本处最易错的地方。 补齐后 39 + `created_at` = 40。混用 INSERT 字段数与物理列数是本处最易错的地方。
""" """
def _physical_columns(self, db: Path) -> list[str]: def _physical_columns(self, db: Path) -> list[str]:
@@ -736,7 +872,7 @@ class TestSQLiteSchemaMode:
assert "ALTER TABLE" in message # 给出可直接执行的补列 SQL assert "ALTER TABLE" in message # 给出可直接执行的补列 SQL
async def test_auto_mode_still_upgrades_the_legacy_table(self, tmp_path): async def test_auto_mode_still_upgrades_the_legacy_table(self, tmp_path):
"""auto + 同款旧表: 现状回归,补列后物理列数 23 → 37。""" """auto + 同款旧表: 现状回归,补列后物理列数 23 → 40。"""
db = tmp_path / "auto_legacy.db" db = tmp_path / "auto_legacy.db"
_make_pre_tenant_db(db) _make_pre_tenant_db(db)
@@ -745,10 +881,10 @@ class TestSQLiteSchemaMode:
recorder.close() recorder.close()
assert self._physical_columns(db) == _EXPECTED_COLUMNS assert self._physical_columns(db) == _EXPECTED_COLUMNS
assert len(self._physical_columns(db)) == 37 assert len(self._physical_columns(db)) == 40
async def test_manual_mode_still_creates_a_fresh_table(self, tmp_path): async def test_manual_mode_still_creates_a_fresh_table(self, tmp_path):
"""manual 只管 ALTER,不管 CREATE: 全新库照建,37 个物理列齐全(设计 §4.2)。""" """manual 只管 ALTER,不管 CREATE: 全新库照建,40 个物理列齐全(设计 §4.2)。"""
db = tmp_path / "manual_fresh.db" db = tmp_path / "manual_fresh.db"
recorder = SQLiteRecorder(db, auto_migrate=False) recorder = SQLiteRecorder(db, auto_migrate=False)
await _record_minimal(recorder, call_id="c-fresh", tenant_id="tenant-a") await _record_minimal(recorder, call_id="c-fresh", tenant_id="tenant-a")
@@ -985,6 +1121,11 @@ class TestPostgresBackfillDiscipline:
"error_body", "error_body",
"attempts", "attempts",
"total_latency_ms", "total_latency_ms",
# 1.3.8 两列: 稳态定义随补列前移,否则每进程首写都去抢锁
"stall_ms",
"stall_count",
# 1.3.10 一列,理由同上
"stream_end",
] ]
def _recorder(self, conn): def _recorder(self, conn):
@@ -2283,6 +2424,18 @@ class _StubOcrTransport:
async def parse_layout(self, *, image, source, call_id): async def parse_layout(self, *, image, source, call_id):
raise NotImplementedError raise NotImplementedError
async def parse_document(self, *, document, source, call_id):
return OcrDocumentTransportResult(
elements=[
OcrDocumentElement(
type="text", bbox=(0.0, 0.0, 1.0, 1.0), page_index=0, text="SYNTH-BODY"
)
],
page_sizes=[(612.0, 792.0)],
markdown="# SYNTH-MD",
raw={"success": True},
)
def _governance(scope, sources): def _governance(scope, sources):
"""embed/OCR 两条链路共用的最小治理装配(真实内存后端,不 mock)。""" """embed/OCR 两条链路共用的最小治理装配(真实内存后端,不 mock)。"""
@@ -2339,6 +2492,20 @@ class TestTextCapCoversEmbedAndOcrChains:
assert json.loads(row["messages"])[0]["content"] == "<ocr:tex…(略 16 字)" assert json.loads(row["messages"])[0]["content"] == "<ocr:tex…(略 16 字)"
assert row["response"] == "识别结果识别结果…(略 32 字)" # 先经 OCR 自有的 200 字上限 assert row["response"] == "识别结果识别结果…(略 32 字)" # 先经 OCR 自有的 200 字上限
async def test_ocr_document_rows_are_capped(self):
"""第三个 OCR 入口的占位串同样受 text_cap 约束(单一遥测出口)。"""
rec = _MemoryRecorder()
client = OcrClient(
**_governance("ocr", [_source(name="m1", model="monkey-ocr")]),
transport=_StubOcrTransport(),
telemetry=rec,
text_cap=8,
)
await client.parse_document(b"%PDF-1.4")
row = rec.rows[0]
assert json.loads(row["messages"])[0]["content"] == "<ocr:doc…(略 20 字)"
assert row["operation"] == "parse_document"
# —— 遥测降级状态(issue #15 C 组): 共用 tracker + 只读快照 + 节流日志 —— # —— 遥测降级状态(issue #15 C 组): 共用 tracker + 只读快照 + 节流日志 ——
@@ -3297,7 +3464,9 @@ class TestRowLevelObservability:
assert error.count("v" * 200) == 3 assert error.count("v" * 200) == 3
assert len(error) < 1200 assert len(error) < 1200
@pytest.mark.parametrize("operation", ["chat", "embed", "recognize_text", "parse_layout"]) @pytest.mark.parametrize(
"operation", ["chat", "embed", "recognize_text", "parse_layout", "parse_document"]
)
async def test_operation_is_given_by_the_call_site_not_the_exception(self, operation): async def test_operation_is_given_by_the_call_site_not_the_exception(self, operation):
"""新列 `operation` 与 `exc.operation` 是两个语义(设计 §5 I1/I2)。""" """新列 `operation` 与 `exc.operation` 是两个语义(设计 §5 I1/I2)。"""
exc = TransientError("boom", status_code=500, operation="download_result") exc = TransientError("boom", status_code=500, operation="download_result")
@@ -3353,7 +3522,7 @@ class TestRecorderShapeGate:
class NarrowRecorder: class NarrowRecorder:
async def record_llm_call(self, *, call_id, model) -> None: ... async def record_llm_call(self, *, call_id, model) -> None: ...
# 若闸内硬编码了 36 个名字,这里必然拒绝 # 若闸内硬编码了 38 个名字,这里必然拒绝
tele.TelemetryEmitter(NarrowRecorder(), scope="LLM", text_cap=None) tele.TelemetryEmitter(NarrowRecorder(), scope="LLM", text_cap=None)
@@ -3454,3 +3623,92 @@ class TestTerminalRowSqlSemantics:
).fetchone()[0] ).fetchone()[0]
== 0 == 0
) )
# 1.3.8 形态(1.3.10 补列前)的表: 38 个 INSERT 字段 + created_at = 39 物理列。
# 冻结字面量(由 _PRE_138_DDL 末行补两列),不从常量切片派生,理由同 _PRE_138_DDL。
_PRE_1310_DDL = _PRE_138_DDL.replace(
" total_latency_ms INTEGER\n);",
" total_latency_ms INTEGER,\n stall_ms INTEGER,\n"
" stall_count INTEGER\n);",
)
class TestStreamEndColumn:
"""1.3.10 结束方式列(#31): 落行、缺省 NULL、补列幂等、manual 告警、各入口取值。"""
async def test_value_round_trips_and_default_is_null(self, tmp_path):
db = tmp_path / "stream_end.db"
recorder = SQLiteRecorder(db, auto_migrate=True)
await _record_minimal(recorder, call_id="c-eof", stream_end="eof_after_finish")
await _record_minimal(recorder, call_id="c-none")
recorder.close()
conn = sqlite3.connect(db)
rows = dict(conn.execute("SELECT call_id, stream_end FROM llm_calls").fetchall())
conn.close()
assert rows == {"c-eof": "eof_after_finish", "c-none": None}
async def test_auto_migrate_backfills_from_138_table(self, tmp_path):
db = tmp_path / "pre1310.db"
conn = sqlite3.connect(db)
conn.execute(_PRE_1310_DDL)
conn.commit()
conn.close()
for call_id in ("c1", "c2"): # 二次初始化幂等
recorder = SQLiteRecorder(db, auto_migrate=True)
await _record_minimal(recorder, call_id=call_id, stream_end="done")
recorder.close()
conn = sqlite3.connect(db)
assert [r[1] for r in conn.execute("PRAGMA table_info(llm_calls)")] == _EXPECTED_COLUMNS
conn.close()
async def test_manual_mode_names_the_new_alter(self, tmp_path, captured_warnings):
db = tmp_path / "manual1310.db"
conn = sqlite3.connect(db)
conn.execute(_PRE_1310_DDL)
conn.commit()
conn.close()
recorder = SQLiteRecorder(db, auto_migrate=False)
await _record_minimal(recorder, call_id="c-manual", stream_end="done")
recorder.close()
conn = sqlite3.connect(db)
assert len([r[1] for r in conn.execute("PRAGMA table_info(llm_calls)")]) == 39
assert conn.execute("SELECT call_id FROM llm_calls").fetchone() == ("c-manual",)
conn.close()
assert "ALTER TABLE llm_calls ADD COLUMN stream_end TEXT" in captured_warnings[0]
async def test_attempt_takes_stream_end_from_response(self):
rec = _MemoryRecorder()
await TelemetryEmitter(rec, text_cap=None, scope="LLM").emit_attempt(
request=_REQ,
source=_source(),
call_id="c",
latency_ms=1,
response=_resp(stream_end="eof_after_finish"),
error=None,
reasoning_applies=True,
operation="chat",
)
assert rec.rows[0]["stream_end"] == "eof_after_finish"
async def test_failed_hit_and_terminal_rows_are_null(self):
rec = _MemoryRecorder()
emitter = TelemetryEmitter(rec, text_cap=None, scope="LLM")
await emitter.emit_attempt(
request=_REQ,
source=_source(),
call_id="c",
latency_ms=1,
response=None,
error="boom",
reasoning_applies=True,
operation="chat",
)
# 命中行显式不信任 response 上的值(即使上游误带)
await emitter.emit_cache_hit(
request=_REQ, response=_resp(cache_hit=True, stream_end="done"), operation="chat"
)
await emitter.emit_terminal_failure(
request=_REQ, call_id="c", error="dead", operation="chat", stats=_MIGRATED_STATS
)
assert [row["stream_end"] for row in rec.rows] == [None, None, None]
+161
View File
@@ -6,6 +6,7 @@ import inspect
import pytest import pytest
from polygateway.types import ( from polygateway.types import (
CALL_OPERATIONS,
USAGE_SOURCES, USAGE_SOURCES,
BackpressurePolicy, BackpressurePolicy,
BreakerConfig, BreakerConfig,
@@ -13,6 +14,10 @@ from polygateway.types import (
Effort, Effort,
GlobalLimits, GlobalLimits,
LLMResponse, LLMResponse,
OcrDocumentElement,
OcrDocumentResult,
OcrDocumentTransportResult,
OcrLayoutElement,
RetryPolicy, RetryPolicy,
SourceConfig, SourceConfig,
ThinkingObservation, ThinkingObservation,
@@ -185,6 +190,19 @@ class TestSourceConfig:
_make_source(missing_done="ignore") _make_source(missing_done="ignore")
def test_source_config_stall_threshold_validation():
"""`stall_threshold_ms`(1.3.8 沉默计量阈值): 默认 1000;0/负/bool → ValueError。"""
assert _make_source().stall_threshold_ms == 1000
assert _make_source(stall_threshold_ms=50).stall_threshold_ms == 50
with pytest.raises(ValueError):
_make_source(stall_threshold_ms=0)
with pytest.raises(ValueError):
_make_source(stall_threshold_ms=-5)
# bool 是 int 子类: True 会被当成 1 静默放行,必须显式拒(P5)
with pytest.raises(ValueError):
_make_source(stall_threshold_ms=True)
class TestEffectiveEstTokens: class TestEffectiveEstTokens:
"""TPM 入场预扣量的派生(est_tokens 解耦设计 §2.2)。""" """TPM 入场预扣量的派生(est_tokens 解耦设计 §2.2)。"""
@@ -293,6 +311,12 @@ class TestAuxTypes:
assert s.thinking_observation is ThinkingObservation.UNKNOWN assert s.thinking_observation is ThinkingObservation.UNKNOWN
def test_transport_result_stall_fields_default_none():
"""TransportResult 沉默两字段(1.3.8)默认 None;旧位置构造不炸(只增不改)。"""
result = TransportResult("c", "t", 1, 2, "measured", None, None, {})
assert result.stall_ms is None and result.stall_count is None
class TestOcrTypes: class TestOcrTypes:
"""M3 OCR 五类型(设计 §3.1): 可构造、frozen、全必填无默认。""" """M3 OCR 五类型(设计 §3.1): 可构造、frozen、全必填无默认。"""
@@ -622,6 +646,85 @@ class TestCallStatsAndContext:
with pytest.raises(dataclasses.FrozenInstanceError): with pytest.raises(dataclasses.FrozenInstanceError):
stats.attempts = 3 stats.attempts = 3
def test_callstats_hedge_fields_default(self):
"""1.3.7 三字段全带默认值: 仅旧三参数构造不炸,无对冲恒 0/0/False。"""
from polygateway.types import CallStats
stats = CallStats(logical_call_id="lc-1", attempts=2, total_latency_ms=15)
assert stats.hedges == 0
assert stats.generation_ms == 0
assert stats.hedge_won is False
explicit = CallStats(
logical_call_id="lc-2",
attempts=2,
total_latency_ms=15,
hedges=1,
generation_ms=42,
hedge_won=True,
)
assert (explicit.hedges, explicit.generation_ms, explicit.hedge_won) == (1, 42, True)
def test_callcontext_record_generation_overwrite_and_accumulate(self):
"""chat/OCR 覆盖(结构化重问最后一轮为准);embedding 分批累加。"""
from polygateway.types import _CallContext
ctx = _CallContext(now=_FakeMonotonic())
ctx.record_generation(100, accumulate=False)
ctx.record_generation(30, accumulate=False)
assert ctx.snapshot().generation_ms == 30
ctx.record_generation(50, accumulate=True)
assert ctx.snapshot().generation_ms == 80
def test_callcontext_register_hedge_counts(self):
"""对冲路实际发出即计数;赢家裁定后一次性登记赢家身份。"""
from polygateway.types import _CallContext
ctx = _CallContext(now=_FakeMonotonic())
ctx.register_hedge(hedge_won=False)
ctx.register_hedge(hedge_won=True)
stats = ctx.snapshot()
assert stats.hedges == 2 and stats.hedge_won is True
def test_snapshot_includes_hedge_fields(self):
"""快照把三字段带出: 裸生成时间与对冲计数不停留在内部状态里。"""
from polygateway.types import _CallContext
ctx = _CallContext(now=_FakeMonotonic())
ctx.record_generation(200, accumulate=False)
ctx.register_hedge(hedge_won=True)
stats = ctx.snapshot()
assert (stats.hedges, stats.generation_ms, stats.hedge_won) == (1, 200, True)
def test_callstats_stall_fields_default_none(self):
"""1.3.8 归因两字段默认 None: 仅旧参数构造不炸(1.3.7 及以前兼容形态)。"""
from polygateway.types import CallStats
stats = CallStats(logical_call_id="lc-1", attempts=2, total_latency_ms=15)
assert stats.stall_ms is None
assert stats.stall_count is None
explicit = CallStats(
logical_call_id="lc-2",
attempts=2,
total_latency_ms=15,
stall_ms=3200,
stall_count=2,
)
assert (explicit.stall_ms, explicit.stall_count) == (3200, 2)
def test_callcontext_record_generation_with_stall_and_snapshot(self):
"""stall 两值经 record_generation 进 snapshot;不传恒 None(embedding/ocr 形态)。"""
from polygateway.types import _CallContext
ctx = _CallContext(now=_FakeMonotonic())
ctx.record_generation(1800, accumulate=False, stall_ms=3200, stall_count=2)
stats = ctx.snapshot()
assert (stats.generation_ms, stats.stall_ms, stats.stall_count) == (1800, 3200, 2)
# 不传(embedding/ocr 既有调用形态): 归因恒 None,None 是"无观测"不是 0
plain = _CallContext(now=_FakeMonotonic())
plain.record_generation(500, accumulate=False)
plain_stats = plain.snapshot()
assert plain_stats.stall_ms is None and plain_stats.stall_count is None
def test_context_counts_attempts_and_freezes_elapsed(self): def test_context_counts_attempts_and_freezes_elapsed(self):
"""快照是同步冻结的时间切片: 登记两次尝试后耗时按注入钟折算成毫秒。""" """快照是同步冻结的时间切片: 登记两次尝试后耗时按注入钟折算成毫秒。"""
from polygateway.types import _CallContext from polygateway.types import _CallContext
@@ -751,3 +854,61 @@ class _FakeMonotonic:
def advance(self, seconds: float) -> None: def advance(self, seconds: float) -> None:
self.t += seconds self.t += seconds
class TestOcrDocumentTypes:
"""#34 设计 §4.1: 文档元素是版面元素 + 内容;结果独立定义。"""
@staticmethod
def _element(**overrides):
base = {"type": "text", "bbox": (0.0, 0.0, 1.0, 1.0), "page_index": 0, "text": "x"}
base.update(overrides)
return OcrDocumentElement(**base)
def test_document_element_is_a_layout_element(self):
"""继承的价值: 按 bbox 裁剪的下游函数可以同时吃两种元素。"""
assert isinstance(self._element(), OcrLayoutElement)
def test_document_element_never_equals_a_layout_element(self):
"""dataclass 的 `__eq__` 要求同类——已知语义,钉死以免下游拿版面元素去比对文档结果。"""
layout = OcrLayoutElement(type="text", bbox=(0.0, 0.0, 1.0, 1.0), page_index=0)
assert self._element() != layout
def test_new_types_are_frozen(self):
element = self._element()
with pytest.raises(dataclasses.FrozenInstanceError):
element.text = "y" # type: ignore[misc]
transport = OcrDocumentTransportResult(elements=[], page_sizes=[], markdown="", raw={})
with pytest.raises(dataclasses.FrozenInstanceError):
transport.markdown = "y" # type: ignore[misc]
def test_result_is_not_a_subclass_of_layout_result(self):
"""父类末尾 `call_stats` 带默认值,子类无默认字段 `markdown` 会在导入期 TypeError。"""
from polygateway.types import OcrLayoutResult
assert not issubclass(OcrDocumentResult, OcrLayoutResult)
def test_result_carries_provenance_five_plus_markdown(self):
fields = [f.name for f in dataclasses.fields(OcrDocumentResult)]
assert fields == [
"elements",
"page_sizes",
"markdown",
"source_name",
"usage",
"latency_ms",
"call_id",
"raw",
"call_stats",
]
class TestCallOperations:
def test_call_operations_matches_the_literal(self):
"""`CALL_OPERATIONS` 此前没有任何读者,与 `CallOperation` 漂移也无人发现。"""
from typing import get_args
from polygateway.types import CallOperation
assert get_args(CallOperation) == CALL_OPERATIONS
assert "parse_document" in CALL_OPERATIONS
+3
View File
@@ -139,6 +139,9 @@ async def test_salvage_override_stays_in_domain(usage):
overlay={}, overlay={},
call_id="cid", call_id="cid",
reasoning_effort=None, reasoning_effort=None,
first_token_event=None,
content_event=None,
response_mode="text",
) )
assert result.usage_source in USAGE_SOURCES assert result.usage_source in USAGE_SOURCES
+1
View File
@@ -41,6 +41,7 @@ _SIGNATURE_TARGETS = [
("EmbeddingClient.embed", EmbeddingClient.embed), ("EmbeddingClient.embed", EmbeddingClient.embed),
("OcrClient.from_env", OcrClient.from_env), ("OcrClient.from_env", OcrClient.from_env),
("OcrClient.recognize_text", OcrClient.recognize_text), ("OcrClient.recognize_text", OcrClient.recognize_text),
("OcrClient.parse_document", OcrClient.parse_document),
("gather_bounded", polygateway.gather_bounded), ("gather_bounded", polygateway.gather_bounded),
("register_provider", register_provider), ("register_provider", register_provider),
] ]