用 Go 手撸 Agent 框架 #2:LLM 是什么——从 token 预测到 API 调用

上一篇说了我为什么想学 Agent------从"会用但不懂"到"能造"。

但在写第一行 Agent 代码之前,有一件事得先搞清楚:LLM(大语言模型)到底是什么东西。

很多人跟我一样,AI 用得很溜。每天用 ChatGPT 问问题,用 Cursor 写代码,用 Claude Code 修 bug。但对底层概念是模糊的:什么是 token?上下文窗口怎么算的?streaming 和 non-streaming 有什么区别?

这些概念不搞清楚,后面写 Agent 的时候会一头雾水。

一句话定义:LLM 就是一个"下一个 token 预测器"

不管产品包装得多么花哨,LLM 的底层机制就一件事:给你一段文本,它预测下一个词(token)是什么。

裸的预训练模型------刚跑完"预测下一个 token"这个训练任务------就是一个统计补全器。你说的每一句话对它来说都是一堆 token,它根据训练数据中学到的统计规律,猜下一个 token 最可能是什么。

scss 复制代码
f([the, capital, of, france]) → [is]
f([the, capital, of, france, is]) → [paris]
f([the, capital, of, france, is, paris]) → [.]

但你正在用的 ChatGPT、Claude 不是裸的预训练模型。 它们在"预测下一个 token"这个基础之上,经过了两个关键步骤:指令微调 (instruction tuning)和人类反馈强化学习(RLHF)。

指令微调教会了模型"对话格式"------知道用户发来的是一条指令,需要以助手的口吻回答。RLHF 进一步把模型训练成"有帮助且无害的助手"------裸模型会补全"如何制造炸弹"的教程,但经过对齐的 Claude 不会,因为它被刻意训练成拒绝有害请求。

所以才有了这样的效果:底层机制是 token 预测,但经过对齐训练后,行为不再是简单的统计补全。可以理解为:底层引擎是概率模型,但上面跑了一个"对齐层"------一套经过训练的价值观和行为规范。(注意:这是概念上的比喻,实际 RLHF 直接修改模型权重,不是在预训练模型外面套一个独立模块。)

那为什么它能写代码、回答问题、甚至做数学题?因为参数足够多(千亿级别),训练数据足够大(整个互联网),这种"下一个词预测"的能力涌现出了令人惊讶的模式识别效果。但它本质上仍然不是一个逻辑推理引擎------它没有符号计算能力,不能保证正确性,可能编造知识。

token:模型眼中的"字"

这是我最常遇到困惑的概念。

你输入一句话,模型看到的不是字符,而是 token。token 是模型对文本的切分单位。

主流的 LLM 使用基于 BPE(Byte Pair Encoding)的分词器。以 cl100k_base 编码器为例,它的基本规则是:常见词尽量作为一个整体,生僻词拆成更小的片段。

比如 "unbelievable" 可能被切分成 "un", "believ", "able" 三个 token,而 "the" 就是一个 token。

中英文的差异很大。英文平均 1 token 大约对应 3-4 个字符(取决于词汇常见程度)。中文平均 1 token 大约对应 1.5 到 1.7 个汉字,具体以 tokenizer 和 API usage 为准。 也就是说,同样字符数的情况下,中文消耗的 token 比英文多。

但这里有个容易误解的地方:中文表达同一含义通常比英文更短。比如"机器学习"字符数远少于"machine learning",但二者的 token 数不会简单按字符数同比例变化------tokenizer 会按常见词、子词和符号切分。所以比较成本时不能只看字符数,要看实际 usage。"中文比英文贵"只针对等字符数比较,不是语义等价比较。

DeepSeek 没有独立的 token 计数 API,后端同学用字符数粗估就够。保守估计 1 token ≈ 2 字符 (英文约 3-4 字符/token,中文约 1.5-1.7 字符/token,折中取 2 只是为了做简单粗估;精确值以 API usage 为准):

go 复制代码
package main

import "fmt"

func estimateTokens(text string) int {
    // 保守估计: 1 token ≈ 2 字符
    return len([]rune(text))/2 + 1
}

func main() {
    en := "Hello, how are you? I'm writing a Go program to call the LLM API."
    fmt.Printf("英文: %s\n", en)
    fmt.Printf("估算 token: %d\n\n", estimateTokens(en))

    cn := "你好,最近怎么样?我正在写一个程序来调用人工智能的接口。"
    fmt.Printf("中文: %s\n", cn)
    fmt.Printf("估算 token: %d\n", estimateTokens(cn))
}

这种粗估对于 token 预算管理已经够用------我们只需要知道"大概还剩多少"而不是"精确到个位数"。

如果想要比字符粗估更准的结果,可以用 tiktoken 参考(cl100k_base 编码器与 DeepSeek 的分词器接近但不完全一致):

bash 复制代码
go get github.com/pkoukk/tiktoken-go@latest

然后调用 tiktoken.GetEncoding("cl100k_base") 获取编码器。注意 GetEncoding 首次运行会从 openaipublic.blob.core.windows.net 下载 tokenizer 文件------大陆网络环境需要配置代理,或用上面的字符粗估替代。但 DeepSeek 精确的 token 计数应以 API 返回的 usage 字段或官方离线 tokenizer 为准,tiktoken 只是近似参考。

第一次 API 调用

搞清楚了 token,现在来发第一个请求。

bash 复制代码
go mod init hello-llm
go get github.com/sashabaranov/go-openai@latest
export DEEPSEEK_API_KEY="你的 key"
go 复制代码
package main

import (
    "bytes"
    "context"
    "encoding/json"
    "errors"
    "fmt"
    "io"
    "log"
    "net/http"
    "os"
    "strings"

    "github.com/sashabaranov/go-openai"
)

// deepseekTransport 注入 thinking: {type: "disabled"}。
// DeepSeek V4 默认开启 Thinking Mode,工具调用场景下不传
// reasoning_content 回后续请求会 400。显式关闭 thinking 避免此问题。
type deepseekTransport struct {
    base http.RoundTripper
}

func (t *deepseekTransport) RoundTrip(req *http.Request) (*http.Response, error) {
    if strings.Contains(req.URL.Host, "deepseek") && strings.Contains(req.URL.Path, "chat/completions") {
        bodyBytes, err := io.ReadAll(req.Body)
        if err != nil {
            return nil, err
        }
        req.Body.Close()

        var bodyMap map[string]any
        if err := json.Unmarshal(bodyBytes, &bodyMap); err != nil {
            return nil, fmt.Errorf("请求体解析失败: %w", err)
        }
        if _, exists := bodyMap["thinking"]; !exists {
            bodyMap["thinking"] = map[string]string{"type": "disabled"}
        }
        modifiedBytes, err := json.Marshal(bodyMap)
        if err != nil {
            return nil, err
        }
        req.Body = io.NopCloser(bytes.NewReader(modifiedBytes))
        req.ContentLength = int64(len(modifiedBytes))
    }
    if t.base != nil {
        return t.base.RoundTrip(req)
    }
    return http.DefaultTransport.RoundTrip(req)
}

func main() {
    config := openai.DefaultConfig(os.Getenv("DEEPSEEK_API_KEY"))
    config.BaseURL = "https://api.deepseek.com"
    // 注入 thinking 禁用
    if httpClient, ok := config.HTTPClient.(*http.Client); ok {
        httpClient.Transport = &deepseekTransport{base: httpClient.Transport}
    }
    client := openai.NewClientWithConfig(config)

    // 测试两段文本(与前面的粗估示例相同,方便对比)
    tests := []struct {
        label string
        text  string
    }{
        {"英文", "Hello, how are you? I'm writing a Go program to call the LLM API."},
        {"中文", "你好,最近怎么样?我正在写一个程序来调用人工智能的接口。"},
    }

    for _, tt := range tests {
        // 字符粗估
        estimated := len([]rune(tt.text))/2 + 1
        fmt.Printf("=== %s ===\n", tt.label)
        fmt.Printf("输入: %s\n", tt.text)
        fmt.Printf("粗估: %d token\n", estimated)

        resp, err := client.CreateChatCompletion(context.Background(), openai.ChatCompletionRequest{
            Model:     "deepseek-v4-flash",
            MaxTokens: 128, // 示例只关心输入 token 对比,限制回复长度
            Messages: []openai.ChatCompletionMessage{
                {Role: openai.ChatMessageRoleUser, Content: tt.text},
            },
        })
        if err != nil {
            log.Fatal(err)
        }

        fmt.Println(resp.Choices[0].Message.Content)
        fmt.Printf("API 实测: 输入 token=%d  输出 token=%d\n",
            resp.Usage.PromptTokens, resp.Usage.CompletionTokens)
        fmt.Printf("(粗估 %d, 误差 %d)\n\n", estimated,
            resp.Usage.PromptTokens-estimated)
    }
}

跑一下,输出大概是(实际数值取决于模型版本和分词器的具体行为):

erlang 复制代码
=== 英文 ===
输入: Hello, how are you? I'm writing a Go program to call the LLM API.
粗估: 31 token
Hello! I'm doing well, thanks for asking...
API 实测: 输入 token=23  输出 token=...
(粗估 31, 误差 -8)

=== 中文 ===
输入: 你好,最近怎么样?我正在写一个程序来调用人工智能的接口。
粗估: 15 token
你好!很高兴见到你...
API 实测: 输入 token=19  输出 token=...
(粗估 15, 误差 4)

粗估不准是正常的------这本来就不是精确算法。但我们做上下文预算管理时只需要知道"大概还剩多少"。英文粗估误差大是因为 tokenizer 按子词切分,不是按字符。更准的近似可用 tiktokencl100k_base),后面的框架 agent/provider.go 会做;精确值以 API usage 为准。

这段代码在做的事:

  1. 使用 go-openai 创建客户端,配置 DeepSeek 的 API 地址和 API Key
  2. 调用 CreateChatCompletion,传入模型名(deepseek-v4-flash,1M 上下文窗口,适合学习)、最大输出 token 数和一个 user 消息
  3. 模型返回一个 ChatCompletionResponseChoices[0].Message.Content 是模型回复文本,Usage 是 token 用量

看一下请求的实际结构。你发的是:

json 复制代码
{
  "model": "deepseek-v4-flash",
  "max_tokens": 128,
  "messages": [
    {"role": "user", "content": "你好,最近怎么样?我正在写..."}
  ]
}

模型返回的是:

json 复制代码
{
  "choices": [{"message": {"role": "assistant", "content": "我很好,谢谢你的关心..."}}],
  "usage": {"prompt_tokens": 19, "completion_tokens": 45}
}

一次请求,一次响应。跟 HTTP 接口一模一样。 这就是我们前面说的------普通 AI 应用就是一个函数。

但这里有个问题:你只告诉了模型"要做什么",没告诉它"怎么回答"。

同样的问题"如何优化数据库查询",你问一个 DBA 和问一个前端,得到的回答差异巨大。LLM 在训练时见过各种角色的语料,不指定身份,它会用"平均风格"回答------通常是详细但啰嗦的教科书式回复。

如果你想要特定风格------简洁、技术向、带代码------就得在提问之外额外告诉模型这件事。 这就是 system prompt 要解决的问题。

system 和 user:两个不同的"输入通道"

API 里除了 role: "user",还有一个 role: "system"------system(系统提示词)

system 和 user 的区别:

  • system --- 设定模型的行为准则。比如"你是一个 Go 语言专家"、"请用简洁的代码回答"。它不是一问一答中的"问题",而是贯穿整个对话的上下文设定
  • user --- 用户的具体问题或指令

在 OpenAI 兼容的 API 里,system 不是一个独立参数,而是以 role: "system" 的消息放在 Messages 数组的最前面。语义完全相同------它仍然是模型的"人设",放在所有用户消息之前作为初始上下文。

go 复制代码
resp, err := client.CreateChatCompletion(context.Background(), openai.ChatCompletionRequest{
    Model:     "deepseek-v4-flash",
    MaxTokens: 256,
    Messages: []openai.ChatCompletionMessage{
        {
            Role:    openai.ChatMessageRoleSystem,
            Content: "你是一个资深的 Go 后端工程师。回答要简洁,优先给代码,少废话。",
        },
        {
            Role:    openai.ChatMessageRoleUser,
            Content: "如何优雅地处理 map 中不存在的 key?",
        },
    },
})

加了 system 之后,模型的回复风格会明显不同。没有 system 时,它可能给你一段详细的解释。加了 system 后,它更可能直接给你一段代码。

system 就是模型的"人设"。 后面写 Agent 时,system prompt 的设计会非常关键------它给模型提供了行为偏好的引导。但要注意:system prompt 是建议性的,不是强制性的。用户输入可能让模型偏离 system 的设定,真正的安全边界需要靠工具层权限控制和服务端校验来保证,而不是靠 text prompt。

到这里,你已经完成了第一次 API 调用,也学会了用 system 指定模型的行为风格。接口通了,角色也设了。但控制模型输出不止于此------还有几个 API 层面的技巧值得一提。

控制模型输出的三个小技巧

system 告诉模型"你是谁",user 告诉模型"要做什么"。但有时候你还想更精细地控制输出------格式、开头、停止位置。这三个技巧都是 API 基础层的能力,不需要等到 Agent 章节。

Few-Shot:给模型看几个例子

不是什么特殊 API,本质就是往 messages 里放几轮"输入→输出"的示例,让模型模仿格式:

go 复制代码
Messages: []openai.ChatCompletionMessage{
    {Role: openai.ChatMessageRoleSystem, Content: "把用户输入转成 JSON。"},
    // 示例 1
    {Role: openai.ChatMessageRoleUser, Content: "北京 晴 28度"},
    {Role: openai.ChatMessageRoleAssistant, Content: `{"city":"北京","weather":"晴","temp":28}`},
    // 示例 2
    {Role: openai.ChatMessageRoleUser, Content: "上海 多云 22度"},
    {Role: openai.ChatMessageRoleAssistant, Content: `{"city":"上海","weather":"多云","temp":22}`},
    // 真正的问题
    {Role: openai.ChatMessageRoleUser, Content: "深圳 小雨 25度"},
}

模型看到前两轮的格式,第三轮自然输出同样结构的 JSON。没有新 API,没有新参数------利用的就是上下文里的模式匹配。

Prefill:预填充模型的开头

在模型回复之前,替它写好开头几个字,让它顺着你的方向继续。

DeepSeek 支持这个能力(Beta 阶段):最后一条 message 设为 role: "assistant" 并带 prefix: true,端点使用 https://api.deepseek.com/beta

json 复制代码
{
  "model": "deepseek-v4-flash",
  "messages": [
    {"role": "user", "content": "用一句话总结 Go 的错误处理哲学"},
    {"role": "assistant", "content": "Go 的错误处理核心是", "prefix": true}
  ]
}

模型从"Go 的错误处理核心是"接着往下写,跳过开场白直奔主题。注意:这是 DeepSeek 的 Beta 能力,不是所有 OpenAI 兼容 API 都支持。 Anthropic 的 Claude API 有原生的 prefill 机制,但 OpenAI 官方 API 没有。用之前查你的模型提供商文档。

上面用 JSON 展示而不是 Go 代码,是因为 go-openaiChatCompletionMessage 目前没有 Prefix 字段。Go 里要用这个能力,需要自定义请求结构或像前面 deepseekTransport 那样在传输层改写请求体。

Stop Sequence:告诉模型在哪里停

stop 是请求参数,告诉模型"遇到这个字符串就停止生成":

go 复制代码
resp, err := client.CreateChatCompletion(ctx, openai.ChatCompletionRequest{
    Model: "deepseek-v4-flash",
    Stop:  []string{"\n\n"},
    Messages: messages,
})

需要区分两个容易混淆的东西:

  • stop(请求参数):你传的"遇到就停"的触发词
  • finish_reason: "stop"(响应字段):模型告诉你"我停了"------但原因可能是自然结束,也可能是命中了你传的 stop 序列

这四个手段加起来,就是 API 层控制模型行为的完整工具箱:system 决定行为基调,Few-Shot 决定模仿格式,Prefill 决定开头方向,Stop Sequence 决定在哪里停。

streaming vs non-streaming:为什么 ChatGPT 不是等半天才出字

但你一定注意到了一个问题:你跑上面代码的时候,是等了几秒然后一口气看到结果的。而你平时用 ChatGPT、DeepSeek Chat 这些产品,字是一个一个往外蹦的。

不是你接口调错了。是我们用的是 CreateChatCompletion------non-streaming 模式。模型在服务端生成完所有 token 之后,一次性把结果打包返回。这就像你去餐厅点餐,后厨炒完所有菜才一起上桌。

CreateChatCompletionStream 则是 streaming 模式。模型每生成一个 token,就通过 SSE(Server-Sent Events)推给你。你可以即时显示每个 token,实现"打字机效果"。

go 复制代码
func demoStreaming(client *openai.Client) {
    stream, err := client.CreateChatCompletionStream(context.Background(), openai.ChatCompletionRequest{
        Model:     "deepseek-v4-flash",
        MaxTokens: 256,
        Messages: []openai.ChatCompletionMessage{
            {Role: openai.ChatMessageRoleUser, Content: "用 Go 写一个斐波那契数列"},
        },
    })
    if err != nil {
        log.Fatal(err)
    }
    defer stream.Close()

    fmt.Print("streaming: ")
    for {
        chunk, err := stream.Recv()
        if errors.Is(err, io.EOF) {
            break
        }
        if err != nil {
            log.Fatal(err)
        }
        if len(chunk.Choices) > 0 && chunk.Choices[0].Delta.Content != "" {
            fmt.Print(chunk.Choices[0].Delta.Content)
        }
        if len(chunk.Choices) > 0 && chunk.Choices[0].FinishReason != "" {
            fmt.Printf("\n\n结束原因: %s\n", chunk.Choices[0].FinishReason)
        }
    }
    fmt.Println()
}

跑起来的效果是:文字一个字一个字地蹦出来,而不是等 3 秒后一次性显示。

non-streaming 和 streaming 的选择在语义层面不影响模型生成的内容 (虽然不同 API 实现中可能存在微小差异),主要影响你收到结果的节奏。同时注意:在 streaming 模式中,finish_reason 只在最后一个 chunk 中提供,流式处理过程中你无法提前知道模型为什么停止生成。后面写 Agent 时我们主要用 non-streaming 方式------因为 Agent 的循环逻辑需要拿到完整的 finish_reason 来做决策(判断模型调完工具后是否给出了最终答案)。

上下文窗口:模型的"内存"

流式和非流式搞清楚了。但你有没有注意 API 返回里还有个 Usage?我们打印的 PromptTokens 就是你发给模型的文本消耗的 token 数。那问题来了:模型最多能处理多少 token?

这就是最后一个基础概念------上下文窗口(context window)。

上下文窗口是指模型一次能处理的 token 总数------输入加输出的总和。

不同模型的上下文窗口大小不同。以系列文章使用的 deepseek-v4-flash 为例,支持 1M token 的上下文窗口。1M token 能装下英文约 75 万词、中文约百余万字------具体以 tokenizer 为准。deepseek-chat / deepseek-reasoner 当前自动映射到 deepseek-v4-flash(官方计划 2026-07-24 废弃旧别名),本文代码统一使用 deepseek-v4-flash

代际之间除了窗口大小,检索效率和长上下文处理能力也有差异------后面我们会遇到上下文压缩的问题,不同模型对压缩结果的"理解力"不一样。

上下文窗口不是无限内存,它有成本。

输入 token 是要收费的。你往上下文里塞的内容越多,每次请求越贵。而且上下文越长,推理越慢------原因是注意力机制的复杂度随序列长度二次增长(O(n²)),Flash Attention 等技术缓解了这个问题但没有消除。长上下文的主要代价不在"接近上限时触发问题",而在每多一个 token 推理就慢一点、贵一点。

go 复制代码
// 模拟:计算一个长文档问答的 token 消耗
func estimateTokens(text string) int {
    return len([]rune(text))/2 + 1 // 保守估计 1 token ≈ 2 字符
}

func main() {
    // 假设你有一篇 5000 字的技术文章,想让模型回答问题
    article := "..." // 5000 字的文章内容
    question := "这篇文章提到的主要观点是什么?"

    tokens := estimateTokens(article + "\n" + question)
    fmt.Printf("估算输入 token: %d\n", tokens)
    // 5000 中文 ≈ 2500-3500 token(粗估)
    // 加上问题 ≈ 2600-3600 token
}

这里有个后端工程师需要建立的心智模型:上下文窗口就是一个有容量限制的缓冲区。 你跟 Redis 的 maxmemory 是一个思路------满了就得决定哪些数据该踢出去。

后面我们会详细讨论怎么管理这个"缓冲区"------截断旧消息、摘要压缩、选择性保留------这些都是 Agent 工程中很实际的问题。

到这一步为止

用后端的脑子重新看一遍 LLM:

  • LLM 是一个函数[]Token → []Token,输入 token 序列,输出 token 序列
  • token 是模型的字符切分单位:英文约 3-4 字符/token,中文约 1.5-1.7 字符/token,具体以模型 tokenizer 为准
  • system 是行为设定,user 是具体指令:两个不同的输入通道
  • Few-Shot / Prefill / Stop Sequence 是三种输出控制手段:分别控制格式、开头方向和停止位置------其中 Few-Shot 通过 messages 实现,Prefill 和 Stop 通过请求字段或厂商扩展实现
  • streaming 是传输方式,不影响模型本身:选哪个取决于你的场景
  • 上下文窗口是有容量限制的缓冲区:跟 Redis maxmemory 一个思路
  • 模型不仅能返回文本,还能返回工具调用指令 :OpenAI 兼容 API 允许你给模型注册工具(定义名字、描述、参数 schema),模型的响应可能不再是纯文本------当你收到 finish_reason: tool_calls 时,Choices[0].Message.ToolCalls 里会包含结构化的工具调用请求,包含工具名和参数 JSON。由你来执行工具,把结果塞回对话。这是下篇 Agent 循环的核心机制

理解了这些,就够了。不需要知道 transformer 的注意力机制是怎么算的,不需要知道模型是怎么训练的。作为一个使用者(和未来的构建者),你只需要知道这个函数的接口和行为特征。

相关推荐
武子康1 小时前
给 Coding Agent 写仓库规则:硬约束、软约束、证据门禁三类拆解 + 6 步可执行闭环
人工智能·aigc·agent
闲余生2 小时前
花 3 个月从零写了个 AI Coding Agent,HumanEval 跑出 87.8%,和 GPT-4o 持平
agent
TrisighT2 小时前
Agent 的 JSON 解析崩了三次——拆完 SDK 源码我才搞懂 arguments 为什么是字符串
aigc·agent·ai编程
nopSled3 小时前
AlphaAvatar v0.6.4 架构升级:统一实时感知、RTC 解耦与在线 ENV Memory
人工智能·架构·实时音视频·agent·多模态
chanalbert4 小时前
Agent 工程化指南(二):记忆与规划 — 让 Agent “记得住“且“做得对“
agent·memory·rag·mcp
森叶17 小时前
从 MCP 到扣子:Agent 平台化演化路径
人工智能·大模型·agent·ai agent·mcp
妙码生花19 小时前
从 PHP 到 AI + Golang,程序员自救转型手记(三十二):增加 admin_rule 模型及数据表迁移
数据库·go·ai编程
还是转转19 小时前
深入认识 Agent - 构建简单的 Agent 框架
ai·agent·函数调用