Eino 的“你好世界“背后发生了什么(第48篇-E34)

系列「企业级 AI Agent 实现拆解」E34 篇,Part 9 源码深度篇第一章。你调用 chatModel.Generate(ctx, messages) 之后,发生了什么?这篇一层一层往下拆,追到真正的 HTTP 请求。

读完这篇你会知道

  • GenerateStream 各自走哪条路径
  • genRequest 做了哪些转换:消息格式、工具 Schema、参数合并
  • 真正的 HTTP 请求在哪里发出,发的是什么 JSON
  • Stream 模式的 goroutine + channel 架构
  • callback 在调用链里的插入点

入口:两层薄壳

调用 chatModel.Generate(ctx, messages) 时,进入的是 eino-ext/components/model/openai/chatmodel.go

go 复制代码
// components/model/openai/chatmodel.go
func (cm *ChatModel) Generate(ctx context.Context, in []*schema.Message, opts ...model.Option) (
    outMsg *schema.Message, err error) {
    ctx = callbacks.EnsureRunInfo(ctx, cm.GetType(), components.ComponentOfChatModel)
    out, err := cm.cli.Generate(ctx, in, opts...)  // ← 直接代理到内层 Client
    if err != nil {
        return nil, convOrigAPIError(err)
    }
    return out, nil
}

ChatModel 是薄壳,只做两件事:

  1. callbacks.EnsureRunInfo------往 context 里塞一个 RunInfo(组件类型 = ChatModel),让 callback 系统知道"这是哪个组件在运行"
  2. cm.cli.Generate------实际的 acl/openai.Client

第一层:genRequest --- 三个转换

acl/openai.Client.Generate 第一步调 genRequest,它做三件事:

1. 参数合并(Options 优先级)

go 复制代码
options := model.GetCommonOptions(&model.Options{
    Temperature: c.config.Temperature,  // 构建时的默认值
    Model:       &c.config.Model,
    // ...
}, opts...)  // 运行时传入的 opts 会覆盖默认值

GetCommonOptions 实现优先级:运行时 opts > 构建时 config 。如果你调 Generate(ctx, msgs, model.WithTemperature(0.0)),温度会覆盖配置文件的值。

2. []*schema.Message[]openai.ChatCompletionMessage

Eino 的 schema.Message 是跨框架的统一类型,OpenAI 有自己的 ChatCompletionMessage。转换发生在 genRequest 里:

go 复制代码
for _, inMsg := range in {
    // 纯文本消息(最常见)
    msg = openai.ChatCompletionMessage{
        Role:    toOpenAIRole(inMsg.Role),    // schema.User → "user"
        Content: inMsg.Content,
        ToolCalls:  toOpenAIToolCalls(inMsg.ToolCalls),
        ToolCallID: inMsg.ToolCallID,
    }
    // 多模态消息(图片/音频)走另外两个函数
    msgs = append(msgs, msg)
}

角色映射:

schema.RoleType OpenAI 字符串
schema.User "user"
schema.Assistant "assistant"
schema.System "system"
schema.Tool "tool"

3. []*schema.ToolInfo → JSON Schema

如果绑定了工具,toToolsToolInfo 转成 OpenAI 的工具格式:

go 复制代码
func toTools(tis []*schema.ToolInfo) ([]tool, error) {
    for _, ti := range tis {
        paramsJSONSchema, _ := ti.ParamsOneOf.ToJSONSchema()  // ← 生成 JSON Schema
        tools = append(tools, tool{
            Function: &functionDefinition{
                Name:        ti.Name,
                Description: ti.Desc,
                Parameters:  paramsJSONSchema,
            },
        })
    }
}

这就是 utils.InferTool 的 struct tag(jsonschema_description)最终变成 HTTP 请求里工具描述的路径。


第二层:HTTP 请求

genRequest 组装好 openai.ChatCompletionRequest 结构体后,Generate 调:

go 复制代码
resp, err := c.cli.CreateChatCompletion(ctx, *req, reqOpts...)

c.cligo-openai SDK 的 openai.Client。它把请求体序列化成 JSON(用 bytedance/sonic,比标准库快),发到:

bash 复制代码
POST https://api.deepseek.com/v1/chat/completions
Content-Type: application/json
Authorization: Bearer <api_key>

{
  "model": "deepseek-chat",
  "messages": [
    {"role": "system", "content": "你是一个助手"},
    {"role": "user", "content": "北京今天天气怎么样?"}
  ],
  "temperature": 1.0,
  "tools": [
    {
      "type": "function",
      "function": {
        "name": "get_weather",
        "description": "查询指定城市的天气",
        "parameters": {
          "type": "object",
          "properties": {
            "city": {
              "type": "string",
              "description": "城市名,如 北京、上海"
            }
          },
          "required": ["city"]
        }
      }
    }
  ]
}

响应回来后,buildGenerateResponseChoices[0],把 OpenAI 字符串角色转回 schema.RoleType,填入 FinishReason 和 token 用量,返回 *schema.Message


callback 插入点

Generate 里有三个 callback 调用点:

go 复制代码
func (c *Client) Generate(...) {
    // 1. 发起请求前
    ctx = callbacks.OnStart(ctx, cbInput)  // cbInput 含消息列表、工具、配置

    resp, err := c.cli.CreateChatCompletion(...)  // ← HTTP 请求在这里

    // 2. 出错时
    if err != nil { callbacks.OnError(ctx, err) }

    // 3. 正常返回时
    callbacks.OnEnd(ctx, &model.CallbackOutput{
        Message:    outMsg,
        TokenUsage: toModelCallbackUsage(outMsg.ResponseMeta),
    })
}

这就是 Langfuse / 自定义 callback 能拿到"这次调用的 token 用量"的原因------token 数从 resp.Usage 里读,在 OnEnd 时推给所有已注册的 handler。


Stream 路径:goroutine + Pipe

Stream 模式有所不同:

go 复制代码
func (c *Client) Stream(ctx context.Context, in []*schema.Message, opts ...model.Option) {
    req, _, _, _, _ = c.genRequest(ctx, in, opts...)
    req.Stream = true                                   // 开关
    req.StreamOptions = &openai.StreamOptions{IncludeUsage: true}  // 最后一帧带用量

    stream, _ := c.cli.CreateChatCompletionStream(ctx, *req)  // 建立 SSE 连接

    sr, sw := schema.Pipe[*model.CallbackOutput](1)     // 内部 channel,容量 1

    go func() {
        defer sw.Close()
        for {
            chunk, err := stream.Recv()  // 逐帧从 SSE 读
            if err == io.EOF { return }
            msg, found, _ := builder.build(chunk)   // chunk → schema.Message
            if !found { continue }
            sw.Send(&model.CallbackOutput{Message: msg}, nil)
        }
    }()

    // 把 channel 的读端包成 StreamReader 返回
    outStream = schema.StreamReaderWithConvert(nsr, ...)
    return outStream, nil
}

关键设计:

  • schema.Pipe 是一个有 buffer 的 channel,sw 写端在 goroutine 里,sr 读端包成 StreamReader 返回给调用方
  • 调用方 for { msg, err := sr.Recv() } 本质上是从 channel 读,每读到一帧就能处理(打字机效果)
  • defer sr.Close()必须调用 ,否则写端 goroutine 卡在 sw.Send() 永远不退出(goroutine 泄漏)

完整调用链

scss 复制代码
你的代码:chatModel.Generate(ctx, messages)
    │
    ▼
[components/model/openai/ChatModel.Generate]
  callbacks.EnsureRunInfo(ctx, ...)     ← 塞 RunInfo 到 context
    │
    ▼
[libs/acl/openai/Client.Generate]
  genRequest(ctx, in, opts...)
    ├── GetCommonOptions(...)            ← 合并运行时/构建时参数
    ├── 消息类型转换: schema.Message → openai.ChatCompletionMessage
    ├── 工具转换: ToolInfo → JSON Schema → openai.Tool
    └── 组装 openai.ChatCompletionRequest
  callbacks.OnStart(ctx, cbInput)       ← 触发所有 handler.OnStart
    │
    ▼
[go-openai SDK / http.Client]
  sonic.Marshal(req) → JSON body
  POST /v1/chat/completions
  Authorization: Bearer xxx
    │
    ▼ (HTTP response)
[buildGenerateResponse]
  resp.Choices[0].Message → schema.Message
  角色字符串 → schema.RoleType
  提取 FinishReason + TokenUsage
    │
    ▼
  callbacks.OnEnd(ctx, output)          ← 触发所有 handler.OnEnd(含 token 用量)
    │
    ▼
返回 *schema.Message 给你

Stream 路径在"POST"之后换成 SSE + goroutine + Pipe。


小结

chatModel.Generate 到 HTTP 请求走了三层:

  1. ChatModel(薄壳) :塞 RunInfo,代理到 acl/openai.Client
  2. Client.genRequest(转换层):参数合并、消息类型转换(schema → OpenAI 格式)、工具 JSON Schema 生成
  3. go-openai SDK:JSON 序列化 → HTTP POST → 反序列化

callback 在 OnStart/OnEnd/OnError 三个点插入,不侵入主流程。

Stream 在第 3 层换成 SSE 连接,goroutine 逐帧读,schema.Pipe channel 桥接到 StreamReader,调用方按需拉取。


代码来源:eino-ext/components/model/openai · eino-ext/libs/acl/openai

相关推荐
红信鸽1 小时前
从 0 到 1:基于 Cursor 与 GPT-5/Claude 4 构建企业级智能重构平台
ai编程
手写码匠2 小时前
注意力机制全家桶:从 Multi-Head 到 GQA 再到 Flash Attention 的手写实现
人工智能·深度学习·算法·aigc
岁岁养乐多2 小时前
Claude Code 的 Prompt 工程:从静态分离到缓存优化的深度解析
prompt·ai编程
FogLetter2 小时前
告别“一次性Prompt”:当AI学会“手艺活”,Skill才是智能体的终极进化
aigc·openai
志凌海纳SmartX2 小时前
构建 Agent Ready 的企业云基础设施(白皮书下载)
人工智能·ai·agent·smartx·企业云·榫卯企业云
惢雨2 小时前
AI 开发技巧(实战技巧)
人工智能·ai编程
怕浪猫3 小时前
第12章 实战项目四:电商销售与服务Agent
aigc·openai·ai编程
AI袋鼠帝7 小时前
再见Fable 5,OpenAI出手了!GPT-5.6真香~
aigc·openai
葫芦和十三12 小时前
图解 MongoDB 30|查询路由与广播查询:带片键定向,不带就广播
后端·mongodb·agent