Agent Development Kit (ADK) · 04 - Model

Posted on Sep 20, 2026

ADK-Go 源码阅读笔记 · Model:统一接口怎样连接不同模型服务

源码版本:ADK-Go v2.4.0,commit 4e57df40cbd35f055ba654b03ffb0e509fdd754a。

系列目录 · 配套阅读:Quickstart、Flow、Tool。

Flow 已经准备好用户消息、历史、指令和工具声明,下一步要把请求交给模型服务。Gemini 和 OpenAI 的请求字段、角色、函数调用与流式事件并不相同;Model 层把这些差异放在适配器中处理,向上提供统一的 LLMRequest → LLMResponse 接口。

实现了同一个 LLM 接口,不代表所有模型支持相同的配置和工具。这篇文章围绕一次天气函数调用,说明统一数据怎样跨过 provider 边界,以及信息在转换中如何被保留或拒绝。

一、源码入口与阅读地图

model/
├─ llm.go                  LLM / LLMRequest / LLMResponse
├─ registry.go             模型名正则 -> Factory
├─ gemini/gemini.go         GenAI SDK 适配器
├─ openaimodel/
│  ├─ openai.go             Responses API 调用与流式收尾
│  ├─ request.go            请求、消息、配置转换
│  ├─ tools.go              函数声明与 tool choice
│  ├─ response.go           返回内容、usage、结束原因
│  └─ stream.go             Responses 事件 -> GenAI 中间结构
└─ apigee/apigee.go         配置代理后委托 Gemini adapter
入口 / 文件阅读时要回答的问题
model/llm.go上层到底传什么、收到什么
Flow.callLLM哪些 callback 返回后不再调用模型
gemini.GenerateContent普通调用和流式调用如何选择
buildOpenAIParams通用请求如何转换成 Responses 参数
streamTranslatorprovider 的 delta 事件怎样转成通用 part
streamingResponseAggregator增量和完整响应怎样共存
model.Register / NewLLM字符串名称怎样解析成实例

二、核心接口与数据结构

1. 两个方法的公共契约

下面是接口与请求结构的源码摘录:

// LLM provides the access to the underlying LLM.
type LLM interface {
    Name() string
    GenerateContent(ctx context.Context, req *LLMRequest, stream bool) iter.Seq2[*LLMResponse, error]
}

// LLMRequest is the raw LLM request.
type LLMRequest struct {
    Model    string
    Contents []*genai.Content
    Config   *genai.GenerateContentConfig

    Tools map[string]any `json:"-"`
}

Name() 提供实例的名字,Flow 用它初始化 req.Model。Gemini 和 OpenAI adapter 实际发送时都优先使用非空的 req.Model,因此 BeforeModel 可以修改同一适配器要请求的模型名。

这并不更换 SDK client、认证或 backend。把 Gemini 实例的 req.Model 改成一个 OpenAI 模型名,不会自动变成 OpenAI 请求。依据:Gemini.modelName、OpenAI 参数初始化。

2. 两份工具信息各自服务谁

LLMRequest
├─ Config.Tools
│    get_weather 名称 / 描述 / 参数 schema
│    -> provider 看见的能力声明
├─ Contents
│    user 问题 / model FunctionCall / user FunctionResponse
│    -> provider 看见的消息与历史
└─ Tools["get_weather"]
     Go 工具对象
     -> Flow 的本地执行表,不序列化给 provider

模型得到的是函数协议,不是进程中的 Go 函数对象。toolutils.PackTool 为普通 FunctionTool 同时准备两份信息,Model adapter 主要处理 Config.Tools,工具执行交回 Flow。

3. 响应是一条数据记录,不只是文本

下面是 LLMResponse 的字段节选,省略引用、logprobs 和部分 metadata:

type LLMResponse struct {
    Content             *genai.Content
    UsageMetadata       *genai.GenerateContentResponseUsageMetadata
    CustomMetadata      map[string]any
    ModelVersion        string
    Partial             bool
    TurnComplete        bool
    Interrupted         bool
    ErrorCode           string
    ErrorMessage        string
    FinishReason        genai.FinishReason
}
字段作用不能混同为
Content文本、FunctionCall、媒体等 parts一定可直接展示的字符串
Partial当前响应是否仍是增量一个新的 Agent step
TurnComplete模型 turn 的完成信号session.Event.IsFinalResponse() 的全部条件
FinishReason停止、长度限制、过滤等原因必然是成功完成
ErrorCode / ErrorMessage响应层面的失败信息iterator 第二项 Go error
UsageMetadataprovider 提供的 token 等统计本地一定可准确重算的值
CustomMetadata适配器保留的额外信息所有对外协议一定完整透传

三、主执行路径:请求转换与响应返回

1. Flow 与 Model 的分界

Flow
  processors -> req
  BeforeModel callbacks
       |
       +-- 提供替代 response -> 不调用 Model
       |
       v
  Model.GenerateContent(req, stream)
       |
       v
  provider 请求 / 响应转换
       |
       v
  LLMResponse / Go error
       |
       v
Flow
  OnModelError(适用时) / AfterModel
  -> session.Event
  -> FunctionCall 分派

回调与事件包装属于 Flow。Model 的 adapter 专注于一次模型请求;它没有完整的 Session 或 Agent tree 控制职责。摘要器也是 model.LLM 的直接调用方,见 LLMSummarizer.SummarizeEvents,所以不能把 Flow 的所有 callback 当成每次模型调用必经的全局 middleware。

2. Gemini:接近原生的数据形状

gemini.NewModel 复制 ClientConfig 和可选的 http.Client 结构,再建立 GenAI client 与 header interceptor。这里是结构级复制,不是任意对象图的深拷贝;例如 Transport 对象可以仍被共享。

GenerateContent 的关键语句摘录如下,省略 header 初始化:

m.maybeAppendUserContent(req)

if stream {
    return m.generateStream(ctx, req)
}

return func(yield func(*model.LLMResponse, error) bool) {
    resp, err := m.generate(ctx, req)
    yield(resp, err)
}

源码:初始化、GenerateContent。

有一个时间边界很重要:SDK 网络调用在 iterator 消费时发生,但 GenerateContent 被调用时已经可能修改 req,例如补 user nudge、Config 和 HTTP headers。因此不要共享同一份可变 request 给并发调用,方法返回迭代函数之前,仍可能先修改请求对象。

3. 普通 Gemini 请求

generate 对 SDK error 包装后返回;没有 candidate 时返回 empty response;正常情况经 Genai2LLMResponse 转换。源码:generate。统一响应主要表达第一 candidate,并不是多候选结果列表。

4. OpenAI:同一份历史如何换成另一种协议

openaimodel 使用 Responses API,不是把 GenAI 请求直接提交到 Chat Completions。以同一个天气调用为例,下面是对象示意,不是完整请求 JSON:

ADK / GenAI                            OpenAI Responses
-----------------------------------------------------------------
user Text "杭州天气?"                 user message / input_text
model Text "我来查一下"                assistant output message / output_text
FunctionCall
  ID = weather-1                       function_call.call_id = weather-1
  Name = get_weather                   name = get_weather
  Args = {city: Hangzhou}               arguments = JSON 字符串
FunctionResponse
  ID = weather-1                       function_call_output.call_id = weather-1
  Response = {temperature: 26}          output = JSON 字符串
Config.SystemInstruction               instructions
Config.Tools.FunctionDeclarations      tools[type=function]

model 历史不能作为 assistant 的 input_text 发出;转换器使用 output message。函数调用和结果也是独立 input items,需要保持调用 ID 配对。依据:convertContents、callTracker。

四、关键细节与分支

1. SSE 同时提供增量与聚合结果

普通文本的示意输出:

response 1: Partial=true   Text="杭州"
response 2: Partial=true   Text="当前气温为 26°C。"
response 3: Partial=false  Text="杭州当前气温为 26°C。"

完整聚合结果不能再当作一个普通增量直接追加到已经显示的文本后面。否则 UI 会出现重复答案。

聚合器保留 part 顺序,把 Thought 与普通文本分别累积,处理函数调用的渐进参数与 thought signature,并保留 usage、grounding、citation 和结束原因。它不是简单的 strings.Join(allChunks)。

2. 流中断时,不保证有最后一条完整响应

Gemini 的 generateStream 在 SDK stream 返回 error 时直接 yield error 并返回,跳过 Close()。消费者提前停止也会直接返回。只有正常消费结束才尝试得到最终聚合结果。

OpenAI 还需要判断 terminal event:response.completed、response.incomplete 与只有 response.created 的流不是同一种收尾。完整 terminal payload 可能补上没有作为 delta 到达的内容或函数调用;未知结束原因不会自动标成 STOP。

源码:Gemini 流结束、OpenAI stream 与 terminal 处理、finalizeStreamResponse。

3. 调用 ID 为什么不能靠工具名替代

OpenAI request converter 对缺失的 FunctionCall.ID 生成 adk-openai-call-N,把它加入待配对队列。FunctionResponse 有显式 ID 时,必须能从队列找到并消费;没有 ID 时,使用最早的 pending ID,队列为空则报错。

FunctionCall #1: get_weather(Hangzhou), id=A
FunctionCall #2: get_weather(Shanghai), id=B

FunctionResponse id=B -> 配回上海调用
FunctionResponse id=A -> 配回杭州调用

仅凭 name=get_weather -> 无法区分两个调用

工具名称说明调用什么,调用 ID 说明结果属于哪一次执行。缺 ID 的 FIFO 补配是一种兼容路径,不是对任意乱序结果的可靠推断。

4. 配置翻译:支持、拒绝与忽略

GenerateContentConfig 采用 GenAI 的数据结构,OpenAI adapter 必须逐项决定怎样处理。

类别例子当前处理
可映射Temperature、TopP、MaxOutputTokens翻译成 Responses 字段
指令与结构化输出SystemInstruction、JSON MIME / schema映射 instructions 与 text format
工具FunctionDeclarations、ToolConfig映射 function tools / tool choice
推理与统计ThinkingConfig、ServiceTier、logprobs受映射表和取值限制
不能直接映射TopK、StopSequences、多个 candidate、Labels、SafetySettings 等返回具名错误
传输超时HTTPOptions.Timeout对整个调用/流施加 Context deadline
跨后端 headersHTTPOptions.Headers有意忽略,不直接带到 OpenAI

这是 adapter 的实现范围,不是关于模型厂商所有能力的声明。字段存在、JSON 可以编码,并不能证明当前 adapter 支持它。源码:配置转换、header 与 timeout 规则。

超时覆盖重试与整个流,也包括消费者处理 range body 的时间。收到首个字节不表示超时计时已经停止。

5. 只支持函数工具的边界

OpenAI adapter 的 ensureFunctionToolOnly 拒绝 GoogleSearch、Maps、Retrieval、CodeExecution 等非函数工具结构;没有函数声明也会报错。把 Quickstart 的模型直接替换成 OpenAI、却保留 geminitool.GoogleSearch{},不会自动得到等价的 OpenAI 搜索工具。

模型接口兼容不等于工具协议兼容。相关源码:convertTools。

6. Go error、ErrorCode、FinishReason

信号例子Flow / 调用方应如何理解
iterator 返回 Go error网络错误、配置转换失败、无法解析函数参数调用失败;普通 Flow 可尝试 OnModelError
LLMResponse.ErrorCode没有可读输出且被过滤或异常截断有响应对象,但它携带失败语义
FinishReason=MAX_TOKENS输出被长度限制截断可能已有内容,不能直接当作完整业务答案
CustomMetadataOpenAI 的额外结束说明是否保留到最终客户端取决于传输层

OpenAI attachFinishSignal 只进一步处理 FinishReasonSafety 和 FinishReasonOther:已有内容时,通过 FinishReason 和 openai_finish_message metadata 保留原因;无可读内容时写 ErrorCode / ErrorMessage。MAX_TOKENS 和 STOP 在该函数直接返回,不因内容为空就自动生成这些错误字段。这个 metadata 可留在进程内和 Session 中,但当前 REST 的逐字段事件映射不会完整透传它。源码:结束信号。

7. Apigee:代理配置与委托

apigee.NewModel("apigee/...")
  -> parseModelName
  -> ProxyURL / API version / backend / headers
  -> genai.ClientConfig
  -> gemini.NewModel
  -> apigeeModel{delegate, name}

GenerateContent(req)
  -> delegate.GenerateContent(req)   # 原样转交 request

Apigee 实现主要负责配置代理访问再委托 Gemini adapter,并没有再造一套通用响应协议。构造阶段解析代理模型名,与执行时重写 req.Model 是不同动作;当前委托函数原样传入 request,不能假设它每次都会自动去掉 apigee/ 前缀。

同样,实现 LLM 不代表 wrapper 自动暴露底层所有附加能力。Flow 的 Live 路径会断言 Client() *genai.Client,需要单独检查具体 adapter / wrapper 是否提供该方法。

8. Registry:按名字构造,要求唯一匹配

model.Register(pattern, factory) 把正则与构造函数登记到进程级 registry;NewLLM(ctx, name) 扫描所有注册项,只接受一个匹配。

注册表:pattern -> Factory

NewLLM("gemini-example")
  +-- 0 个匹配 -> 未找到错误
  +-- 1 个匹配 -> 释放读锁,再调用 Factory
  +-- 2+ 个匹配 -> 歧义错误;不按注册顺序选第一个

注册重复的 pattern 或非法正则会 panic;匹配使用 MatchString,需要全名匹配时用 ^ / $。SDK provider 不会因为 import 了某个包就自动注册;应用需要显式登记 factory,或者直接使用 gemini.NewModel / openaimodel.NewModel。

配置片段:

model.Register("(?i)^gemini-.*$", func(ctx context.Context, name string) (model.LLM, error) {
    return gemini.NewModel(ctx, name, nil)
})

源码:registry.go。这个 registry 选择构造方式,不是跨 provider 的请求故障转移或自动重试服务。

五、重新梳理:运行时架构与边界

层次输入输出负责的转换
Flow request processorsAgent 配置与 SessionLLMRequest指令、可见历史、工具声明
Model adapterLLMRequestprovider 请求名称、角色、函数调用、配置
SDK / provider网络请求原生响应或 stream远端模型服务协议
adapter / aggregator原生响应LLMResponsepart、usage、错误、完整聚合
FlowLLMResponseEvent / 工具执行回调、动作与循环
RunnerEventSession 与调用方输出追加非 partial、对外协议入口

Model 层不保存对话历史,也不执行普通 FunctionTool。它返回 FunctionCall,Flow 才能将它解释为本地调用。模型名路由、HTTP 代理与 Agent transfer 也分别属于不同边界。

六、这个模块解决了什么问题

1. 让上层用同一种请求与响应协作

Flow 可以面向 LLM 编写循环,不必分别处理每个 provider 的 SDK 类型。模型测试替身也可以实现这两个方法,用确定性的响应验证上层执行逻辑。

2. 把不可忽略的协议差异留在适配器中

角色映射、函数参数 JSON、调用 ID、流式 terminal 和结束原因都影响正确性。它们需要明确转换或报错,不能只做字段名称替换。

3. 公共结构的代价

以 GenAI 类型作为共同请求表示,使 Gemini 路径较直接,也让其他 adapter 承担字段映射与兼容限制。切换模型时应检查工具、多模态、schema、错误和流式语义,而不是只改一个名称。

七、重点结论与常见误解

  • LLMRequest 进入 adapter,LLMResponse 返回 Flow;Event 在更上层形成。
  • 模型返回 FunctionCall 不代表工具已执行。
  • Partial 服务于增量展示,完整响应服务于后续执行与历史。
  • 相同 interface 下的 provider 能力、附加接口和错误边界仍可能不同。
常见误解实际行为
req.Model 改了就自动切换 backend只改变当前 adapter 的模型名称参数
发给模型的是 req.Tools 中的 Go 对象provider 收到的是 Config.Tools 声明
最后一个 chunk 一定就是 final aggregate正常完成、错误与消费者停止走不同分支
OpenAI-compatible 地址都能使用该 adapter端点需要支持这里使用的 Responses API 形状
工具名足以匹配调用结果同名并发调用还需要 call ID
registry 多项匹配时按注册顺序挑选返回歧义错误
LLM 接口意味着 Live 支持Live 还依赖额外 client 能力

用天气示例检查理解:将 get_weather 的参数从 map 转成 JSON 字符串的是 adapter;真正查询天气的 handler 属于 Tool;把结果加入下一次请求的是 Flow 的历史构造路径。

八、相关源码与进一步阅读

源码 / 测试关注点
llm.go请求和响应契约
gemini.go / gemini_test.gorequest model override、配置复制、headers、流式
openaimodel/request.go角色、ID、schema、参数支持范围
openaimodel/openai.go / openai_test.goterminal、增量、完整响应、错误
stream_aggregator.go内容聚合与 part 顺序
registry_test.go单匹配、歧义、重复注册与并发
apigee.go代理配置与委托

继续阅读:Flow → Tool;回到应用入口:Quickstart。