上一篇说了我为什么想学 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 按子词切分,不是按字符。更准的近似可用 tiktoken(cl100k_base),后面的框架 agent/provider.go 会做;精确值以 API usage 为准。
这段代码在做的事:
- 使用
go-openai创建客户端,配置 DeepSeek 的 API 地址和 API Key - 调用
CreateChatCompletion,传入模型名(deepseek-v4-flash,1M 上下文窗口,适合学习)、最大输出 token 数和一个 user 消息 - 模型返回一个
ChatCompletionResponse,Choices[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-openai 的 ChatCompletionMessage 目前没有 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 的注意力机制是怎么算的,不需要知道模型是怎么训练的。作为一个使用者(和未来的构建者),你只需要知道这个函数的接口和行为特征。