Skip to content

[Spring AI] Request/Usage Adapter와 Advisor E2E 연결 #39

Description

@HuitaePark

문제와 현재 코드 근거

  • DefaultLedgerAdvisor.before()는 현재 BudgetEvaluator.evaluate(tags)만 호출하며 token/context/cost preflight와 reservation이 없다.
  • after()ledgerManager.record()로 listener를 먼저 호출한 뒤 같은 usage 비용을 다시 계산해 addCost()하므로 correlation과 idempotency가 없다.
  • DefaultUsageExtractor는 provider usage를 정규화하지만 reservation의 actual 입력으로 연결되지 않는다.
  • 현재 build는 Spring AI 1.1.4를 사용하지만 0.1.0 지원 범위와 Advisor error/cancel capability가 아직 계약으로 고정되지 않았다.
  • Spring AI 1.1.4 BaseAdvisor 기본 구현은 downstream non-streaming 예외에서 after()를 호출하지 않고, streaming은 finish-reason response에서만 after()를 호출한다. error/cancel cleanup을 before()/after()만으로 구현할 수 없다.
  • ChatOptions.getModel()getMaxTokens()는 nullable이며 현재 sample처럼 request에 값이 없을 수 있다.
  • Advisor order가 0이므로 이후 Advisor가 prompt를 변경하는 경우 TokenPilot이 실제 전달될 텍스트를 검사하지 못할 수 있다. Spring AI terminal advisor의 structured-output augmentation도 일반 Advisor 이후에 일어난다.

지원 범위와 목표 흐름

MVP 필수 지원은 non-streaming call()의 TEXT_ONLY/명시된 request scope다.

ChatClientRequest
→ Spring request adapter
→ model/reserved-output resolution
→ Core token/context check
→ #45 conservative safeUpperBoundCost
→ checkAndReserve
→ markInFlight
→ provider/downstream chain
→ DefaultUsageExtractor
→ AccountingService.commit or reconciliation-required
→ newly applied accounting event 1회

Spring AI 타입은 token-pilot-spring-ai 내부에 머물고 Core/Accounting 계약에 노출하지 않는다. 정책·가격·reservation·정산 순서는 #45와 #46 계약을 호출하며 Advisor 안에 복제하지 않는다.

non-streaming lifecycle 구현

BaseAdvisor.before()/after() 기본 동작에 의존하지 않고 adviseCall() 또는 동등한 around-call 구현이 lifecycle을 소유한다.

  1. request adaptation과 preflight 실패는 reservation을 만들지 않고 provider 호출 전에 종료한다.
  2. reservation 생성 후 dispatch 이전 TokenPilot 내부 오류는 release한다.
  3. markInFlight() 이후 downstream 예외는 provider dispatch 여부를 확실히 증명할 수 없으므로 보수적으로 RECONCILIATION_REQUIRED 처리한다.
  4. 성공 응답의 provider usage가 유효하면 AccountingService.commit(...)을 한 번 호출한다.
  5. 성공 응답이지만 usage unavailable, extraction/cost 계산 오류 또는 통화 불일치이면 0원 commit하지 않고 RECONCILIATION_REQUIRED로 남긴다.
  6. post-call accounting/listener 실패가 이미 받은 provider 응답을 임의로 뒤집지 않도록 [ADR] 단일 accounting writer와 unresolved liability·event 실패 정책 정의 #46 failure policy를 적용한다.
  7. duplicate callback/command에서는 AccountingService의 idempotent 결과를 재사용하고 ledger/metric event를 다시 만들지 않는다.

Spring AI 1.1.4 Advisor API만으로 “provider 내부 dispatch 직전”을 완전히 관찰할 수 없다는 제한을 문서화한다. 0.1.0은 확인 불가능한 downstream 오류를 actual zero/release보다 pending liability로 보존한다.

model과 reserved output resolution

호출 전 ModelRegistry와 TokenBudget을 사용할 수 있도록 resolution 순서를 고정한다.

modelId:
request ChatOptions.model
→ token-pilot.spring-ai.default-model-id
→ fail-closed MODEL_UNRESOLVED

reservedOutputTokens:
request ChatOptions.maxTokens
→ token-pilot.spring-ai.default-reserved-output-tokens
→ fail-closed OUTPUT_RESERVATION_UNRESOLVED

request scope와 framing

  • message role과 각 message text를 Core preflight 입력으로 변환한다. 모든 text를 구분자 없이 단순 결합하지 않는다.
  • #31의 TEXT_ONLY upper bound는 provider request framing 전체에 대한 exact count가 아니다.
  • system/user/assistant/tool message별 지원 여부와 media/tool schema/structured-output augmentation 포함 여부를 [Build/Compatibility] 0.1.0 Java·Spring Boot·Spring AI 지원 기준 확정 #47 capability matrix에 명시한다.
  • 지원하지 않는 media/tool/request scope는 기본 fail-closed 또는 명시적 설정의 fail-open 중 하나로 구조화된 결과를 반환하며 exact 지원처럼 표시하지 않는다.
  • MVP에서 TEXT_ONLY 값을 context admission에 사용할 때는 configurable/request adapter framing headroom을 더하거나, 결과와 문서에서 “지원 request scope의 text bound”로 제한한다.
  • preflight 위치는 가능한 마지막 user Advisor가 되게 하되 terminal advisor가 이후 추가하는 framing은 위 headroom/제한사항으로 다룬다.

Streaming 컷라인

MVP에서 budget/preflight enforcement가 활성화된 streaming 요청은 애매한 cleanup을 시도하지 않는다.

budget 또는 preflight enforcement enabled + stream()
→ STREAMING_UNSUPPORTED_FOR_ENFORCEMENT
→ reservation 0건
→ provider invocation 0회
  • 호출 전 fail-closed 동작을 adviseStream() 또는 별도 StreamAdvisor에서 명시적으로 구현한다.
  • ledger-only/비 enforcement 모드의 streaming 호환 동작도 #47에서 결정하고 문서화한다.
  • chunk별 exact count, partial usage, cancel 후 과금 판정과 advanced streaming reconciliation은 post-MVP다.
  • streaming이 budget 경로를 조용히 우회하도록 두지 않는다.

구현 가이드

  1. #47에서 Spring AI 1.1.4의 request, Usage, Advisor, observation과 streaming capability matrix 및 0.1.0 지원 범위를 확정한다.
  2. Spring request adapter, model resolver, reserved-output resolver와 request-scope result를 작은 컴포넌트로 분리한다.
  3. namespaced request context에 requestId, attemptId, idempotency key와 reservation ID를 전달한다. ThreadLocal은 사용하지 않는다.
  4. idempotency key가 없을 때 caller 제공을 요구할지 생성할지 [ADR] 단일 accounting writer와 unresolved liability·event 실패 정책 정의 #46 계약을 따르고, 생성 키가 upstream retry 중복 제거를 보장한다고 과장하지 않는다.
  5. Advisor는 [Core] 보수적 PreflightCostBound 계산 계약과 구현 #45 cost bound와 [Budget] commit/release lifecycle와 actual reconciliation 구현 #37 AccountingService만 조율한다. 비용을 재계산하거나 store를 직접 갱신하지 않는다.
  6. autoconfigure는 새 Core/Budget/Accounting 서비스를 주입하되 budget 비활성화 시 기존 ledger-only 호환 경로를 유지한다.
  7. provider와 AccountingService probe를 사용해 provider invocation과 transition 횟수를 독립적으로 검증한다.

E2E 테스트

  • 정상 non-streaming call: reserve → in-flight → actual commit, provider 1회, active reservation/pending liability 0
  • context/budget/missing-pricing BLOCK: provider 0회, reservation·ledger·비용 기록 0회
  • request model 누락 + configured default 없음: MODEL_UNRESOLVED, provider 0회
  • maxTokens 누락 + configured reserved output 없음: OUTPUT_RESERVATION_UNRESOLVED, provider 0회
  • configured model/output fallback이 있으면 해당 versioned model과 safe bound로 예약
  • reservation 후 markInFlight 전 오류: release 1회
  • markInFlight 후 downstream 오류: RECONCILIATION_REQUIRED 1회, 0원 commit/release 없음
  • 성공 응답의 usage unavailable/extraction 오류: pending liability 유지, provider 응답 보존
  • 요청 model과 응답 model이 다르면 둘을 reconciliation 정보에 보존
  • 기존 cache-read/create/reasoning normalization을 재사용
  • unsupported media/tool/request scope가 설정 정책대로 fail-closed되고 reason이 보존
  • budget/preflight 활성 streaming: provider 0회, reservation 0건
  • Core public/runtime dependency에 Spring AI 타입이 유입되지 않음
  • #47에서 선택한 지원 버전의 fake provider E2E 통과

Acceptance criteria

  • non-streaming adviseCall() 또는 동등한 around lifecycle이 reserve부터 reconciliation까지 소유한다.
  • request와 provider usage가 동일 reservation/AccountingService lifecycle로 연결된다.
  • 모든 context, budget, pricing, model/output resolution BLOCK은 provider invocation 전에 집행된다.
  • nullable model/maxTokens의 fallback과 fail-closed 계약이 테스트된다.
  • TEXT_ONLY, framing headroom과 unsupported request scope가 결과/Javadoc/문서에 명시된다.
  • 지원하는 성공·오류 경로가 COMMITTED, RELEASED 또는 RECONCILIATION_REQUIRED 중 하나로 끝난다.
  • budget/preflight 활성 streaming은 provider 호출 전에 명시적으로 차단된다.
  • 정책·가격·reservation·회계 로직이 Advisor에 복제되지 않고 [Core] 보수적 PreflightCostBound 계산 계약과 구현 #45/[ADR] 단일 accounting writer와 unresolved liability·event 실패 정책 정의 #46/[Budget] commit/release lifecycle와 actual reconciliation 구현 #37 API를 사용한다.
  • post-call accounting/listener 실패가 provider 응답을 뒤집지 않는다.
  • Core는 Spring AI 없이 동일하게 동작한다.
  • 지원 버전, Advisor order와 streaming/request-scope 제한이 #47에 문서화된다.
  • fake provider로 API key 없이 E2E가 반복 실행된다.

제외 범위

  • exact BPE
  • provider 전체 request framing의 exact token count
  • RAG/tool/media 전체 지원
  • retry/fallback
  • streaming chunk exact counting, partial billing과 cancel reconciliation
  • metric 이름과 publisher — [Observability] TokenPilot 고유 Micrometer 지표 구현 #40
  • 모든 provider/복수 Spring AI 버전 호환성 보장

의존관계와 순서

Source

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestmvpTokenPilot 0.1.0 MVP scope

    Type

    No type

    Projects

    Status
    Todo

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions