Provider abstraction và adapter Anthropic
Provider abstraction và adapter Anthropic
Tổng quan
Định nghĩa mô hình LLM chuẩn hóa của hango — Message, Block, Event, StopReason, Usage — và interface Provider streaming một method, rồi cài nó trên anthropic-sdk-go v1.74. Mô hình chuẩn hóa được thiết kế ngay bây giờ để adapter OpenAI ở Phase 12 chỉ cần tổng hợp phần khung block; làm sai đường nối này là sai lầm đắt nhất có thể mắc trong roadmap. Không có công việc về database: giai đoạn này hoàn toàn là code thư viện cộng một lệnh CLI smoke.
Yêu cầu
Chức năng
provider.Providercó một method streaming duy nhất trả vềiter.Seq[Event]. Bên gọi không bao giờ duyệt lại một sequence để “gom lại sau”: họ đẩy từng event vào mộtAccumulatorcó trạng thái trong khi chuyển tiếp nó tới sink.AccumulatorcóPush(Event) error,Message() provider.Message,Partial() provider.Message,Usage() Usage,Stop() StopReason. Khi có lỗi hoặc context bị hủy, bên gọi giữ message dở dang từPartial()cùng với lỗi — đường hủy ở Phase 6 và đường compaction ở Phase 10 đều phụ thuộc việc phần dở dang đó có sẵn.- Các block gồm text, thinking (kèm signature), tool_use, tool_result;
[]Blockmarshal thành JSON ổn định để lưu jsonb ở Phase 4. - Các thinking block đi vòng nguyên vẹn (text + signature) về lại provider ở lượt kế tiếp.
- Mọi stop reason của Anthropic đều ánh xạ sang các hằng số của hango, gồm cả
model_context_window_exceeded.pause_turnánh xạ sangStopReason PauseTurn, mà adapter lập tức đưa ra thànhErrUnsupportedStop: hango không đăng ký server tool nào của Anthropic, nên mộtpause_turnchỉ có thể có nghĩa là provider đã làm điều gì đó mà hango không biểu diễn được trong mô hình block chuẩn hóa. Không có logic resume nào ở bất kỳ đâu trong roadmap. Thêm server tool (và việc chuyển tiếp raw block mờ mà chúng đòi hỏi) là một việc theo dõi trongplan.md. - Các lỗi có kiểu:
ErrRateLimited(mangRetryAfter),ErrOverloaded,ErrContextOverflow,ErrAuth,ErrInvalidRequest,ErrProvider,ErrUnsupportedStop(mang chuỗi stop reason thô). - Retry với exponential backoff và jitter khi gặp lỗi 429/5xx/mạng, tôn trọng
Retry-After; số lần retry có giới hạn và có thể hủy theo context. Usagemang các token input, output, reasoning, cache-read và cache-write, với cache-write được tách thànhCacheWrite5mvàCacheWrite1h. Anthropic tính giá hai TTL khác nhau; một trường cache-write duy nhất khiến việc tính giá đúng ở Phase 13 bất khả, và việc tách này phải có trong mô hình chuẩn hóa ngay từ đầu.hango probe --provider anthropic --prompt "..."stream một completion ra stdout để kiểm tra thủ công.- Langfuse chỉ nhận các span LLM và agent. Một processor
filter/langfuse_genai_onlytrên pipelinetraces/langfusebỏ mọi span không có attributegen_ai.operation.name, nên các span HTTP, health-check và database chỉ nằm ở Tempo (quyết định ngày 2026-10-14). Phase 1 gửi mọi span tới Langfuse thuần túy để chứng minh đường xuất dữ liệu. Các spaninvoke_agentvàexecute_toolcủa Phase 5 cũng manggen_ai.operation.name, nên chúng qua cùng bộ lọc. Langfuse hiển thị các span agent không có span HTTP cha của chúng, đó là góc nhìn chủ ý.
Phi chức năng
internal/providerkhông import package hango nào ngoài stdlib; nó không được importstore(registry nhận biết tenant ở Phase 12 tiêm credential vào, chứ không đảo chiều dependency).- Test mang tính xác định: một provider
faketrong package cùng các test golden-event trên các chuỗi event SDK đã ghi lại. Không có mạng tronggo test. testing/synctestcho test thời gian retry/backoff.
Kiến trúc
// internal/provider/provider.go
type Provider interface {
Name() string
DefaultModel() string
Stream(ctx context.Context, req Request) (iter.Seq[Event], error)
}
type Request struct {
Model string
System []Block // text blocks; cache breakpoints set via Block.CacheControl
Messages []Message
Tools []ToolDef
MaxTokens int
Temperature *float64
Thinking *ThinkingConfig // nil = disabled
Metadata map[string]string
}// internal/provider/message.go
type Role string // "user" | "assistant"
type Message struct {
Role Role `json:"role"`
Blocks []Block `json:"blocks"`
}
type Block struct { // exactly one non-nil
Text *TextBlock `json:"text,omitempty"`
Thinking *ThinkingBlock `json:"thinking,omitempty"`
ToolUse *ToolUseBlock `json:"tool_use,omitempty"`
ToolResult *ToolResultBlock `json:"tool_result,omitempty"`
CacheControl *CacheControl `json:"cache_control,omitempty"`
}
type ThinkingBlock struct {
Text string `json:"text"`
Signature string `json:"signature"` // echoed back verbatim or tool use breaks
Redacted []byte `json:"redacted,omitempty"`
}
type ToolUseBlock struct {
ID string `json:"id"`
Name string `json:"name"`
Input json.RawMessage `json:"input"`
}
type ToolResultBlock struct {
ToolUseID string `json:"tool_use_id"`
Content []Block `json:"content"`
IsError bool `json:"is_error,omitempty"`
}
type StopReason uint8 // Unknown, EndTurn, MaxTokens, StopSequence, ToolUse, Refusal, PauseTurn, ContextExceeded
// PauseTurn is terminal for hango: it is surfaced as ErrUnsupportedStop, never resumed. // Red team: D4// internal/provider/event.go
type EventKind uint8 // MessageStart, BlockStart, TextDelta, ThinkingDelta, SignatureDelta,
// ToolInputDelta, BlockStop, MessageStop, Error
type Event struct {
Kind EventKind `json:"kind"`
Index int `json:"index,omitempty"` // block index; OpenAI maps tool_call delta Index here
Delta string `json:"delta,omitempty"`
Block *Block `json:"block,omitempty"` // BlockStart only; ToolUse carries ID+Name, empty Input
Stop StopReason `json:"stop,omitempty"`
Usage *Usage `json:"usage,omitempty"`
Err error `json:"-"`
}
// Red team: F7 — stateful accumulator, not a terminal fold over the sequence, so a caller can
// stream each event onward and still hold a consistent partial message at any point.
type Accumulator struct{ /* ordered in-progress blocks by index, usage, stop, err */ }
func NewAccumulator() *Accumulator
func (a *Accumulator) Push(ev Event) error // returns the terminal error carried by an Error event
func (a *Accumulator) Message() Message // finalized; valid after MessageStop
func (a *Accumulator) Partial() Message // best-effort snapshot at any point, incl. after an error
func (a *Accumulator) Usage() Usage
func (a *Accumulator) Stop() StopReasonGiai đoạn này không có bảng nào.
Instrumentation
- Một span cho mỗi lời gọi provider: tên
chat {model}, kind CLIENT, được tạo bên trong adapter để mọi cài đặt provider đều có nó miễn phí thông qua một helper dùng chung tronginternal/provider/trace.go. - Attribute:
gen_ai.operation.name=chat,gen_ai.provider.name=anthropicvàgen_ai.system=anthropic(Langfuse vẫn ghi tài liệu tên cũ, nên phát cả hai),gen_ai.request.model,gen_ai.response.model,gen_ai.request.max_tokens,gen_ai.request.temperature,gen_ai.request.stream=true,gen_ai.response.id,gen_ai.response.finish_reasons,gen_ai.response.time_to_first_chunk,gen_ai.usage.input_tokens,gen_ai.usage.output_tokens,gen_ai.usage.cache_read.input_tokens,gen_ai.usage.cache_write.input_tokens,gen_ai.usage.reasoning.output_tokens. - Attribute của Langfuse:
langfuse.observation.type=generation,langfuse.observation.model.name,langfuse.observation.completion_start_time,langfuse.observation.usage_details(JSON tường minh để việc tính giá cache token không phụ thuộc vào việc Langfuse suy luận tên key semconv, và mang cả phần tách cache-write 5m/1h), vàlangfuse.observation.input/langfuse.observation.outputchỉ được ghi khiHANGO_TRACE_LLM_CONTENT=truevàtrace_llm_contentcủa tenant là true. Cả hai cổng được kiểm tra tại đúng một nơi đặt content attribute; mọi key được ghi ở đó đều có tronginternal/telemetry/content_attrs.go, và collector loại bỏ danh sách đó trên cả nhánh Langfuse cho các tenant đã opt-out. - Retry: mỗi lần thử là một span event
provider.retryvớiattempt,delay_ms,error.type,http.response.status_code,retry_after_s. - Metric: histogram
gen_ai.client.token.usage(attributegen_ai.token.type=input|output|cache_read|cache_write,gen_ai.request.model,gen_ai.provider.name),gen_ai.client.operation.duration,gen_ai.client.operation.time_to_first_chunk,gen_ai.client.operation.time_per_output_chunk, counterhango.provider.errorsvớierror.type. - Lỗi:
span.RecordErrorcùngspan.SetStatus(codes.Error)vàlangfuse.observation.level=ERRORvớilangfuse.observation.status_message.
Các file liên quan
Tạo mới
internal/provider/provider.go—Provider,Request,ToolDef,ThinkingConfig,CacheControl.internal/provider/message.go— các kiểu message/block cùng kiểm tra trongMarshalJSON/UnmarshalJSONrằng đúng một trường block được đặt.internal/provider/event.go— các kiểu event vàAccumulator.internal/provider/errors.go— các lỗi có kiểu vàIsRetryable(err) bool.internal/provider/trace.go— helper span dùng chung cho mọi adapter (bắt đầu, đặt attribute, đo thời gian chunk đầu, ghi usage, trạng thái lỗi) cộng các metric instrument.internal/provider/retry.go—Do(ctx, policy, fn)với exponential backoff, jitter vàRetry-After.internal/provider/fake.go— provider theo kịch bản được dùng bởi test vòng lặp của mọi giai đoạn sau.internal/provider/anthropic/provider.go— dựng adapter vàStream.internal/provider/anthropic/convert.go—Requestcủa hango sang tham số SDK, block của SDK sang block của hango.internal/provider/anthropic/events.go— event union của SDK sangEventcủa hango.internal/provider/anthropic/testdata/*.json— các chuỗi event đã ghi (text, tool_use, thinking, overflow,pause_turncho khẳng địnhErrUnsupportedStop).internal/provider/*_test.go,internal/provider/anthropic/*_test.go.cmd/hango/probe.go— subcommandprobe.docs/learning/03-provider-abstraction.md.
Sửa đổi
internal/config/config.go—HANGO_ANTHROPIC_API_KEY,HANGO_ANTHROPIC_BASE_URL,HANGO_DEFAULT_MODEL,HANGO_PROVIDER_MAX_RETRIES,HANGO_PROVIDER_TIMEOUT.internal/config/config.go— thêmHANGO_TRACE_LLM_CONTENT(mặc định false).internal/app/app.go— dựng provider mặc định; bỏ qua khi không có key nào được đặt.deploy/otelcol/config.yamlvà test generator của nó tronginternal/telemetry— thêmfilter/langfuse_genai_onlyvào pipelinetraces/langfuse.docs/system-architecture.md,docs/learning/README.md,docs/observability-cloud-setup.md.
Các bước triển khai
- Thêm
github.com/anthropics/anthropic-sdk-gov1.74.0. - Viết
message.go. Thêm helperBlock.Kind() stringvàValidate() errorbắt buộc đúng một trường được điền. Viết test JSON round-trip cho mọi kiểu block, gồm cả một thinking block có signature. - Viết
event.govớiAccumulator: duy trì một slice có thứ tự các block đang dở dang được đánh index theoIndex;BlockStartgieo mầm, các delta nối thêm (các delta input của tool nối vào một buffer được parse ởBlockStop),MessageStopchốt.Pushtrả về lỗi của một eventErrorvà giữ lại mọi thứ đã tích lũy, đểPartial()có ý nghĩa sau một thất bại hoặc một lần hủy. Test: đẩy một chuỗi bị cắt ngắn, khẳng địnhPartial()chứa các block đã hoàn tất và đoạn text đang dở. - Viết
errors.govàretry.go.IsRetryablebao gồm 429, 5xx, timeout củanet.Errorvàio.ErrUnexpectedEOF. Backoff cơ sở 500ms, hệ số 2, trần 30s, full jitter, tôn trọngRetry-Afterkhi nó lớn hơn. Test bằngtesting/synctest. - Viết
anthropic/convert.go: ánh xạRequest.SystemsangSystemBlocksvớiCacheControl, message sang cácMessageParam, định nghĩa tool sangToolUnionParam. Các thinking block phải được phát lại dưới dạngThinkingBlockParamvới signature gốc; thinking bị che được phát lại dưới dạngRedactedThinkingBlockParam. - Viết
anthropic/events.go: vòng lặpstream.Next()/Current(), type switchevent.AsAny()tạo ra các event của hango. Ánh xạContentBlockStartEventthànhBlockStart(tool_use mang ID và Name),TextDelta/ThinkingDelta/SignatureDelta/InputJSONDeltathành các loại delta của chúng,ContentBlockStopEventthànhBlockStop,MessageDeltaEventthành stop reason + usage,MessageStopEventthànhMessageStop. Đưastream.Err()ra thành một eventErrorkết thúc để sequence vẫn chỉ có một kiểu giá trị. - Ánh xạ các stop reason:
end_turn,max_tokens,stop_sequence,tool_use,refusal,pause_turn,model_context_window_exceededsang các hằng số của hango;model_context_window_exceededcòn đưa raErrContextOverflowđể Phase 10 kích hoạt compaction, vàpause_turnđưa raErrUnsupportedStopcùng lý do thô để vòng lặp làm run thất bại với một thông báo tường minh, grep được thay vì âm thầm cắt cụt một lượt. - Viết
anthropic/provider.go: dựng SDK client với base URL, API key, timeout; tắt retry ở tầng SDK và dùngretry.Docủa hango để chính sách backoff chỉ có một cài đặt.Streamtrả về mộtiter.Seq[Event]màyieldcủa nó tôn trọngctx.Done()và luôn đóng SDK stream khi thoát. - Viết
fake.go: một provider dựng từ một kịch bản[][]Eventtrả về chuỗi kế tiếp cho mỗi lời gọi, với các hook để tiêm lỗi. Đây là test double mà các giai đoạn vòng lặp sẽ dùng. - Viết test golden giải mã
testdata/*.jsonthành các event union của SDK và khẳng định chuỗi event hango được tạo ra cùngMessageđược tích lũy. - Thêm
cmd/hango/probe.govà kiểm tra thủ công với API thật (loại khỏigo test). - Viết
internal/provider/trace.govà nối nó vàoStreamcủa adapter Anthropic: bắt đầu span trước request, ghitime_to_first_chunkở delta đầu tiên, đặt các attribute usage và finish-reason ởMessageStop, kết thúc span trong mộtdefer. Thêm một test in-memory span-recorder khẳng định đầy đủ tập attribute cho một stream theo kịch bản, và khẳng định các content attribute vắng mặt khiHANGO_TRACE_LLM_CONTENT=false. - Chạy một probe Langfuse thực nghiệm 10 phút (`hango probe --trace` với Langfuse Cloud) để chốt ba hành vi mà nghiên cứu đã đánh dấu CHƯA XÁC MINH: (a) `gen_ai.input.messages`/`gen_ai.output.messages` có hiển thị thành input/output của observation hay chỉ `gen_ai.prompt`/`gen_ai.completion`, (b) engine tính giá của Langfuse tính cho cache token của Anthropic theo các key usage nào, (c) `gen_ai.operation.name` một mình có suy ra được kiểu observation không. Ghi kết quả vào learning doc. Phương án dự phòng đã định trước bất kể kết quả: luôn đặt các attribute `langfuse.*` một cách tường minh.
- Thêm
filter/langfuse_genai_onlyvào pipelinetraces/langfuse, saufilter/langfuse_disabled, với điều kiện spanattributes["gen_ai.operation.name"] == nil. Mở rộng test config của collector để khẳng định processor có mặt trên nhánh Langfuse và vắng mặt khỏi các nhánh lgtm và Grafana Cloud. Xác minh thủ công: saucurl /readyzvà một lầnhango probe --trace, Langfuse chỉ hiển thị generationchat {model}, trong khi Tempo hiển thị cả hai trace. - Viết learning doc, gồm một mô tả vẽ tay của giao thức SSE thô của Messages API.
Dàn ý tài liệu học
docs/learning/03-provider-abstraction.md phải có:
- Khái niệm: vì sao một harness cần mô hình message riêng thay vì truyền các kiểu của SDK đi khắp nơi.
- Giao thức SSE thô của Anthropic Messages API, được giải thích dù SDK đã che đi: khung
text/event-stream, các dòngevent:/data:, chuỗimessage_start→content_block_start→content_block_delta*→content_block_stop→message_delta→message_stop, các eventping, và việc tích lũypartial_jsoncó nghĩa là gì. Kèm một bản ghi event nguyên văn có chú thích. - Các lựa chọn thiết kế: một method streaming với
iter.Seq[Event], lỗi được mang như một event kết thúc, khung block là dạng chuẩn hóa, mộtAccumulatorcó trạng thái (không phải một phép fold kết thúc) để streaming và tích lũy chia sẻ một lượt duyệt và một message dở dang sống sót qua thất bại, retry do hango sở hữu thay vì SDK. - Vì sao `pause_turn` không thể xảy ra với hango và do đó là một lỗi chứ không phải một vòng lặp resume: nó tồn tại cho server tool của Anthropic, mà hango không đăng ký cái nào.
- Đánh đổi: SDK so với wire client viết tay (chi phí giải mã union, tích lũy, signature của thinking); interface khả năng tùy chọn so với các trường khả năng.
- Thinking block: vì sao signature phải được trả lại nguyên vẹn và điều gì hỏng nếu không.
- So sánh với goclaw:
Providerbốn method (Chat/ChatStream/DefaultModel/Name),Options map[string]any,RawAssistantContent json.RawMessagecho việc trả lại thinking,SSEScannervàRetryDodùng chung, các interfaceThinkingCapabletùy chọn. - Cách debug tầng này: trong Langfuse, mở observation generation để xem model, tham số, finish reason, token usage và (khi bật content tracing) chính xác prompt và completion; trong Grafana, dùng các histogram
gen_ai.client.operation.durationvà token-usage theo từng model, và đọc các span eventprovider.retrytrong Tempo để biết một lời gọi chậm là do backoff rate-limit hay do model thật sự chậm. Kết quả của probe ánh xạ Langfuse, với tên attribute chính xác đã hoạt động. - So sánh với deepagents: nó ủy thác hoàn toàn cho lớp trừu tượng model của LangChain và chỉ thấy các message đã chuẩn hóa — hango phải tự xây thứ mà LangChain cung cấp.
- Vì sao hango đi khác: một method streaming (non-streaming là một phép fold), block có kiểu thay vì trả lại JSON thô, stop reason là một enum tường minh gồm cả trigger cho compaction.
- Bài tập: thêm một kiểu block mới; làm cho khoảng trống khung của OpenAI trở nên cụ thể bằng cách phác thảo cách
BlockStartsẽ được tổng hợp. Tùy chọn: viết một vòng lặp think-act-observe 30 dòng dùng một lần trong một file_test.gotrênprovider.Fakevà một tool, để thấy hình dạng của vòng lặp trước khi Phase 5 xây phiên bản production. Không có code sản phẩm. - Ngân sách thời gian: 12h triển khai + 3h viết learning doc (bản ghi giao thức wire là phần tốn kém nhất).
Tiêu chí thành công
-
go build ./... && go vet ./... && go test ./...pass khi không có truy cập mạng. - Test golden bao phủ các chuỗi text-only, tool_use, thinking-with-signature và
model_context_window_exceeded; fixturepause_turnkhẳng địnhErrUnsupportedStopđược trả về và không có nỗ lực resume nào. -
Accumulatortrên một stream tool_use đã ghi cho ra mộtMessagecóToolUseBlock.Inputlà JSON hợp lệ khớp fixture, vàPartial()trên một stream bị cắt trả về các block đã hoàn tất cho tới lúc đó. -
UsagemangCacheWrite5mvàCacheWrite1htách biệt từ một fixture dùng cả hai TTL. - Một thinking block được giải mã và mã hóa lại qua
convert.gogiữ nguyên signature từng byte (test khẳng định). - Test retry dưới
testing/synctestcho thấy số lần thử có giới hạn,Retry-Afterđược tôn trọng, và trả về ngay khi context bị hủy. -
hango probe --prompt "say hi"stream token với API thật (kiểm tra thủ công, ghi trong learning doc). - Một span CLIENT
chat {model}với đầy đủ tập attribute hiển thị trong Tempo, và cùng lời gọi đó hiển thị thành mộtgenerationtrong Langfuse với model, usage và finish reason được điền. - Langfuse chỉ nhận các span mang
gen_ai.operation.name. Một request/readyzhiện trong Tempo nhưng không hiện trong Langfuse, và test config của collector khẳng địnhfilter/langfuse_genai_onlychỉ nằm trên nhánh Langfuse. - Với
HANGO_TRACE_LLM_CONTENT=false, không có văn bản prompt hay completion nào xuất hiện trên bất kỳ span nào trong Tempo hay Langfuse (kiểm tra thủ công cộng test span-recorder). - Với
HANGO_TRACE_LLM_CONTENT=truenhưngtrace_llm_content=falsecủa tenant, span không mang content attribute nào cả (test span-recorder), và mọi content key mà adapter có thể ghi đều có mặt trongcontent_attrs.go(test liệt kê chúng).
Đánh giá rủi ro
| Rủi ro | Khả năng x Tác động | Dấu hiệu | Cách xử lý đã quyết định trước |
|---|---|---|---|
| Mô hình chuẩn hóa không biểu diễn được các delta của OpenAI, buộc phải viết lại ở Phase 12 | Medium x Critical | Adapter Phase 12 cần các loại event mới | Index + BlockStart/BlockStop tường minh được chọn chính là để OpenAI có thể tổng hợp khung; một ghi chú thiết kế trong learning doc nêu các quy tắc tổng hợp trước khi adapter tồn tại |
| Event union của SDK thay đổi khi nâng cấp | Medium x Medium | Lỗi biên dịch trong events.go | Việc ánh xạ được cô lập trong một file; ghim version SDK trong go.mod và nâng cấp có chủ đích |
| Signature của thinking bị mất khi lưu trữ hoặc chuyển đổi | Medium x High | Provider trả 400 ở lượt thứ hai của một run có thinking | Test round-trip trong giai đoạn này; Phase 4 thêm một test round-trip lưu jsonb cho cùng block đó |
| Retry hai lần (SDK cộng hango) gây đứng lâu | Medium x Medium | Độ trễ tăng vọt, request trùng lặp | Retry của SDK bị tắt tường minh lúc dựng client; được khẳng định bằng một comment và một test dựng client |
Bên tiêu thụ iter.Seq bỏ dở sequence, làm rò SDK stream | Medium x Medium | Profile rò goroutine ở Phase 6 | Stream đóng SDK stream trong một defer bên trong hàm sequence; Phase 6 thêm test rò rỉ |
Rollback
Một package thuần cộng thêm cùng một khối cấu hình. Hoàn tác nghĩa là xóa internal/provider và subcommand probe; các Phase 1 và 2 không bị đụng tới vì internal/app chỉ dựng provider khi HANGO_ANTHROPIC_API_KEY được đặt.