☰
从Token到Decoder:GPT类大模型机制与工程接入
2026/11/3 3:44:43 网站建设 项目流程

简介:这份 PDF 为 O’Reilly 于 2024 年出版的《Developing Apps with GPT-4 and ChatGPT》英文原版电子书,作者为 Olivier Caelen 与 Marie-Alice Blete,面向具备一定编程基础、希望将大模型能力落地到实际应用中的开发者与产品技术人员。全书从 GPT-4 与 ChatGPT 的基本概念、能力边界和 API 调用思路讲起,进而延伸到智能聊天机器人、内容生成器等典型场景,帮助读者建立从模型理解到应用构建的系统认知,并了解提示设计、交互组织与原型实现中的常见考虑。资源包内共 1 个 PDF 文件,大小约 2.86MB,便携易读,并保留原书章节式组织,适合按章节查阅、本地归档或在项目选型时快速参考。目前已有 1133 人浏览学习,说明其在 AI 应用开发入门与资料查阅方面具有一定关注度。读者可从中获得聊天机器人设计、内容生成应用构思、开发流程梳理等参考,并借助原书结构继续拓展实践方向。

1. 一句「天气不错」背后:GPT-4 和 ChatGPT 到底在算什么

很多人第一次用 ChatGPT 时的直觉是:它读懂了我的问题,然后组织语言回答。这个直觉是错的,而且错得很关键。模型拿到 prompt 之后做的唯一动作,是把输入切成 token,对「下一个 token 是什么」算一个概率分布,挑一个出来接上去,再把这个新 token 塞回输入,重算一遍。所谓回答,是这个过程重复几十上百次的结果。搞不清这一点,后面所有工程决策——上下文怎么管、为什么输出不稳定、为什么同样的 prompt 两次结果不同——都会变成玄学。GPT-4 和 ChatGPT 属于同一类大语言模型,底层是 transformer 的 decoder 部分,理解这个预测循环,是读懂这类应用的第一块地基。

2. 分词与上下文窗口:模型看到的从来不是你以为的那段文字

大语言模型收到的不是字符串,是一串整数。字符串到整数之间隔着一个 tokenizer,这一步决定了模型「能看见什么」。很多工程问题的根都在这儿:为什么同样一段需求描述,中文版本比英文版本更费额度;为什么一段格式化 JSON 能把上下文吃光;为什么把提示词从 500 字改写成 400 字,效果反而变差。把 tokenizer 和上下文窗口这两件事拆开看,后面做预算、做裁剪、做限流才有依据,否则全是拍脑袋。

2.1 子词切分:token 既不是字,也不是词

语言模型最早的做法是 n-gram:统计训练语料里「前几个词之后最常跟哪个词」,用频率直接预测下一个。这条路简单,但漏洞明显——它记的是共现次数,不是语义,遇到训练集里没出现过的组合就抓瞎,生成的文本常常语法断裂。后来 RNN 和 LSTM 把序列信息压进隐状态,能捕捉更长的依赖,机器翻译类工具因此受益多年,但它们受制于顺序计算,规模上不去。

token 的问题在 n-gram 时代就存在:词表是固定的,没见过的词就是 OOV。子词切分的思路是把词拆成更小的片段,高频片段保留、低频词拆开,词表规模可控,理论上任何字符串都能被表示。代价是切分结果经常反直觉,一个完整的英文单词可能被拦腰切成两段,一个汉字可能占一到两个 token。原文给过一个很直观的例子:The weather is nice today, so I decided to被切成["The", "wea", "ther", "is", "nice", "today", ",", "so", "I", "de", "ci", "ded", "to"]。注意weather断在了中间,decided被拆成三段——模型看到的就是这些碎片,它靠注意力把这些碎片重新拼成语义。

每类模型都绑定自己的 tokenizer,换模型等于换一把尺子。所以做 token 预算时,不能拿「字数」当口径,只能拿「当前模型的切分结果」当口径。下面这个函数是我在还没接上真实 tokenizer 时用来做粗估的,误差够用,但绝不能当计费依据:

def rough_token_count(text: str) -> int: """粗估 token 数:英文约 4 个字符 1 个 token,中日韩字符按 2 个 token 计。 只用于容量预估,真实计费必须以模型自带 tokenizer 的输出为准。""" ascii_chars = sum(1 for ch in text if ord(ch) < 128) cjk_chars = len(text) - ascii_chars # 除以 4 来自英文语料的经验均值:单词平均 4~5 个字符,加上空格和标点 return int(ascii_chars / 4) + cjk_chars * 2 print(rough_token_count("The weather is nice today, so I decided to")) print(rough_token_count("今天天气不错,所以我决定出去走走"))

两个参数值得说清楚:ascii_chars / 4里的 4 是英文平均字符/token 比,代码和 JSON 场景要往下调,因为括号、引号、缩进都会各自占 token,密度比自然语言高得多;cjk_chars * 2的 2 是偏保守的上界,实际多数汉字落在 1 到 2 之间,按 2 算是为了给上下文留安全垫。

正因为切分规则不透明,token 密度在不同内容上差异极大。做容量规划时,我用的是下面这组经验区间(具体数值随模型和 tokenizer 波动,只能当参考量级):

内容类型大致 token 密度容易踩的坑
英文自然语言100 token ≈ 75 词长单词、专有名词会被拆成多段
中文自然语言1 汉字 ≈ 1~2 token按字数估会严重低估
源代码1 行 ≈ 10~20 token缩进和标点占比高
格式化 JSON1 条记录可能上百 token键名重复出现,浪费窗口
表格/日志粘贴密度极高常成为超限的第一嫌疑人

2.2 上下文窗口:8192 和 32768 限制的到底是什么

窗口不是「回答最多能有多长」,而是「单次前向计算能同时看到多少 token」。GPT-4 提供过 8,192 和 32,768 两个窗口规格,这个数字是输入加输出的总和。很多人第一次超限就是因为把它理解成了输出上限:提示词塞了七千多 token,再让模型写一篇长文,直接撞墙。

从机制上看,窗口限制来自注意力的计算代价。每个 token 都要和其他所有 token 算相关度,序列长度翻倍,计算量和显存占用大致翻四倍。这不是工程实现偷懒,是机制本身的成本结构。所以长上下文的实际体验是「又贵又慢」,而且中间位置的信息容易被稀释——模型对开头和结尾的注意力权重通常更高,「中间遗忘」在长文档问答里是能复现的现象。

工程上的正确姿势是做预算,而不是事后截断:

CONTEXT_LIMIT = 8192 # 模型窗口总容量 RESERVED_FOR_OUTPUT = 1500 # 预留给回答,别让输入把窗口吃干净 SAFETY_MARGIN = 200 # 给 tokenizer 估算误差留的余量 def build_prompt(system_prompt, history, user_input, budget=None): budget = budget or (CONTEXT_LIMIT - RESERVED_FOR_OUTPUT - SAFETY_MARGIN) used = rough_token_count(system_prompt) + rough_token_count(user_input) kept = [] # 从最近的历史往前保留,越早的对话优先级越低 for turn in reversed(history): cost = rough_token_count(turn) if used + cost > budget: break kept.insert(0, turn) used += cost return system_prompt, kept, user_input

三个常量的含义分别是:CONTEXT_LIMIT是模型声明的窗口总量;RESERVED_FOR_OUTPUT是给生成阶段留的空间,不留够就会出现「话说到一半断掉」;SAFETY_MARGIN是给粗估函数兜底的余量。这个函数的核心逻辑是「从近到远填充」,因为对话场景里最近几轮几乎总是最相关的,早期历史用摘要替代比原样保留更划算。

提示:不要指望「把文档整篇丢进去让它自己找」。窗口够大不等于模型会用,检索、分段、加锚点这些预处理,在长上下文模型上依然必要。

3. 注意力机制:self-attention 和 cross-attention 究竟在算什么

transformer 之所以成为分水岭,不是因为参数量更大,而是因为它在结构上解决了「一个词的含义由谁决定」这个问题。在它之前,RNN 系模型把句子当一个顺序流,第 5 个词的信息要经过 4 次传递才能影响第 10 个词,传得越远衰减越厉害。注意力机制换了个问法:处理某个位置时,直接看整句里哪些位置和它相关,按相关度加权取信息。这个改动让长距离依赖变成一步操作,顺带把顺序计算变成了矩阵运算。

3.1 self-attention:一个代词是怎么被上下文改写的

self-attention 的作用范围是同一段输入内部。它要做的事,是给序列里每个位置算出一个「结合了上下文的新表示」。以「她收到了同事的称赞」为例,模型处理「她」这个位置时,需要判断这个代词指向谁。做法是给每个词算出三个向量:query(我在找什么)、key(我能提供什么)、value(我实际携带的信息)。用「她」的 query 和句子里每个词的 key 做点积,得到一组相关度分数,softmax 归一化成权重,再用权重对所有 value 加权求和,结果就是「她」这个位置的新表示。权重会明显偏向「同事」这类词,于是「她」的表示里混入了相关实体的信息。

下面这段是示意实现,用简化打分函数代替了真正的 Q/K 矩阵乘法,目的在看清流程而不是性能:

import math def softmax(xs): m = max(xs) # 先减最大值,避免 exp 溢出 exps = [math.exp(x - m) for x in xs] s = sum(exps) return [e / s for e in exps] # 权重之和恒为 1 def attention_weights(words, query, relevance): """返回 query 位置对句子中每个词的注意力权重""" scores = [relevance(query, w) for w in words] # 真实实现是 Q 与 K 的点积 return dict(zip(words, softmax(scores))) words = ["她", "收到", "了", "同事", "的", "称赞"] # 简化相关度:与「人」相关的词给高分,其余给 0 def rel(q, w): return 2.0 if w in ("同事", "称赞") else 0.0 weights = attention_weights(words, "她", rel) for w, a in weights.items(): print(f"{w}: {a:.3f}")

代码里有三处关键:softmax里的减最大值是为了数值稳定,序列一长,指数很容易溢出;scores的计算在真实模型里是Q @ K.T / sqrt(d_k),除以维度平方根是为了防止点积随维度增大而爆炸;最后的加权求和把「谁和谁相关」变成了「信息怎么流动」。这三个动作堆叠多层,就形成了模型对语义的抽象——原文举的例子是,多词组合可以涌现出「某人的同事」这种原文里没有单独出现的概念。

3.2 cross-attention:编码器的表示如何被解码器逐词取用

cross-attention 和 self-attention 的唯一区别是数据来源:query 来自解码器当前正在生成的位置,key 和 value 来自编码器对输入的表示。它回答的问题是「我接下来要输出的这个词,应该参考输入的哪一部分」。

原文给了一个翻译场景的例子:输入一句描述天气的话,要生成目标语言中表示「晴朗」的那个词,此时 cross-attention 会把权重压到输入里表示「阳光」「天气」的那几个 token 上,其余部分被压到接近零。它就是聚光灯——决定当前这一步该看哪儿。这解释了为什么翻译类模型在长句上比早期的循环模型稳:每个输出词都重新扫一遍完整输入,而不是依赖一个被压缩过的定长向量。

放到应用开发视角,cross-attention 的存在直接决定了架构选型。做翻译、摘要、改写这类「输入到输出有明显映射」的任务,编码器-解码器结构天然占优,因为编码器可以专心把输入压成一个高质量表示,解码器反复取用;而纯生成式任务没有明确的输入源,这套机制就派不上用场。

3.3 并行化:为什么 transformer 能把循环结构挤出主流

循环结构的致命伤是顺序依赖:算第 t 步必须先算完第 t-1 步,一个序列只能一个位置一个位置往前推。这在 GPU 上等于把并行能力浪费掉,训练规模被卡死在硬件利用率上。transformer 把整句一次性映射成矩阵,所有位置的注意力可以同时算,训练时可以把成千上万个序列拼成一个大 batch 并行推进。

这个差别不是性能优化级别的,而是能力边界级别的。只有训练吞吐上去了,才可能把互联网规模的语料喂进去;只有语料规模上去了,模型才会表现出小模型没有的能力。2017 年这套结构提出时本来是为序列到序列任务准备的,真正的意外收获是它让「堆数据堆算力」这条路变得可行,大语言模型正是沿着这条路长出来的。

4. 模型架构选型:GPT 为什么只保留 decoder

看过结构图的人常有一个疑问:原始 transformer 明明有编码器和解码器两块,GPT 为什么砍掉一半还能工作?答案在于任务形态不同。编码器擅长把一段文本压成一个表示,适合分类、匹配、抽取这类「理解」任务;解码器擅长在给定前缀的条件下续写,适合生成。GPT 从设计之初就只做一件事——预测下一个 token,所以它只需要解码器,也只需要自注意力,不需要 cross-attention 那条从编码器到解码器的信息通道。

4.1 encoder-only、decoder-only、encoder-decoder 的分工

三种结构对应三种任务假设。以编码器为主的双向模型,训练时能同时看到左右两侧的上下文,适合判断「这句话是什么情感」「这两个句子是否同义」,但它不能生成连贯长文本,因为它没有「按顺序产出」的训练目标。编码器-解码器结构服务于序列到序列任务,输入和输出是两段不同的序列,cross-attention 负责在两者之间搭桥。只保留解码器的结构则加了一个关键约束:因果掩码,每个位置只能看到它左边的 token,右边一律屏蔽。训练目标因此变成「给定前文,预测下一个」。

因果掩码是 GPT 类模型能用作生成器的根本原因。如果训练时允许看到后文,模型会学会抄答案,推理时没有后文可抄,表现立刻崩掉。掩码把训练和推理的条件严格对齐了——两边都只有前缀。

结构可见范围典型任务能力边界
仅编码器双向分类、匹配、抽取不擅长开放式生成
编码器+解码器编码双向、解码单向翻译、摘要、改写结构重,推理成本高
仅解码器单向(因果掩码)对话、续写、生成理解类任务靠提示词模拟

4.2 自回归循环:一次前向只产出一个 token

生成过程听起来像流水线,实际是一步一停。模型每次前向只输出「下一个 token」的概率分布,选出一个,追加到序列末尾,然后拿整段新序列再跑一次。写 300 个 token 的回答,就要跑 300 次前向。这个循环是所有延迟和成本的来源,也是流式输出能成立的原因——不用等全文写完,每出一个 token 就能推给前端。

def generate(model, tokenizer, prompt, max_new_tokens=300, temperature=0.7, stop_token=None): ids = tokenizer.encode(prompt) for _ in range(max_new_tokens): # 只把窗口内的上下文喂进去,超出的部分直接丢弃 window = ids[-model.context_limit:] logits = model.forward(window)[-1] # 取最后一个位置的输出分布 probs = softmax([l / temperature for l in logits]) # 温度调节分布陡峭程度 next_id = sample(probs) # 按概率采样,不是取最大值 ids.append(next_id) if stop_token is not None and next_id == stop_token: break return tokenizer.decode(ids)

三个参数决定了输出风格。temperature越小,分布越尖锐,采样越接近取最大值,输出更稳定但也更死板;越大越随机,创意场景可用,事实性任务上就是自找麻烦。max_new_tokens是硬性刹车,防止模型在开放结尾上无限续写。stop_token决定什么时候收尾,缺了它就容易出现「回答完了还在继续编」的场面。

真实实现里还会有 KV cache:每步重算整段注意力太浪费,把已经算过的 key/value 缓存下来,新 token 只补算增量部分。这是长回答能跑得动的前提,也是为什么上下文越长、显存占用越高的直接原因。

5. 常见问题与排查:接入大模型最容易翻车的五件事

下面这几条都是我在真实项目里踩过的,现象看起来各不相同,根因基本都能追到前四章讲的机制上。按「现象—原因—解决」记下来,下次遇到同类问题能省掉大半天排查时间。

5.1 输入侧:预算没算清,后面全是连锁反应

第一条:回答说到一半被硬切。现象是输出结尾突然断在半个句子上,没有标点也没有结束语。原因是输入把窗口占满了,模型没有剩余空间继续生成,或者触发了输出侧的硬上限。解决方法是按 2.2 的预算表预留输出空间,长文档场景改成「分段处理 + 结果汇总」,而不是指望一次性吞下全文。

第二条:多轮对话越聊越傻。现象是聊到十几轮后模型开始忘记前面的设定,或者重复回答很早之前的问题。原因很简单——历史消息是原样追加的,窗口被早期内容占满,最新的用户输入反而被挤到边缘。解决办法是给历史做分层:最近两三轮保留原文,更早的压成一段摘要,摘要里只保留已确认的事实和用户偏好,不保留寒暄。

第三条:同一个提示词,中文版比英文版贵一截。现象是同样的需求描述,中文输入的 token 数明显更高,长会话下更容易超限。原因在 2.1 讲的切分密度差异。处理方式是做双语对照测试,如果业务允许,把结构化的指令部分写成英文、把用户数据保留原文,能省下可观的窗口空间。这个优化听起来土,但在窗口吃紧的场景里非常有效。

5.2 输出侧:把概率输出当确定性数据用

第四条:流式输出拼接后出现乱码。现象是前端按 chunk 拼起来的文本里偶尔夹杂半个汉字或问号。原因在于流式返回的单位是 token 而不是字符,一个汉字可能跨两个 chunk,直接用字符串拼接就会把多字节字符劈开。正确的做法是在服务端维护增量解码器,按字符边界输出,或者前端先缓冲再判断末尾是否为完整字符。这个问题在纯英文场景下几乎不会暴露,一上中文就翻车。

第五条:把模型返回的内容直接当 JSON 解析。现象是偶发的解析失败,或者字段类型和预期不符。根本原因是模型的输出本质是自由文本,即使你在提示词里写「只返回 JSON」,它也只是「大概率」返回合法 JSON,尤其在字段为空或内容较长时容易多出解释性文字。稳妥的做法是三层防护:提示词里给出完整 schema 和示例,解析前先剥离代码块标记,解析失败时带着错误信息重试一次并降低 temperature。仍然失败就降级到规则兜底,不要让异常直接抛到用户面前。

注意:这五条里有四条的根因都落在「token 是不可见的计量单位」上。做接入时先把 token 计数打点做出来,很多问题会从「玄学」变成「看一眼监控就清楚」。

6. 设计取舍:把模型当概率组件,别当大脑

做到这里会发现一个规律:GPT 类模型的每一个「缺陷」,都能在机制里找到出处。输出不稳定,因为它本来就是按概率采样;长文档效果衰减,因为注意力在长序列上会被稀释;成本随对话轮数线性上涨,因为每轮都要重算整段上下文。这些不是 bug,是设计的必然结果。所以架构上的关键决策不是「怎么让它更准」,而是「在哪些环节允许它不准」。

我一般会按三步做取舍。第一步,划分边界:把模型放在「生成候选」的位置,把「校验和决策」留在确定性代码里。结构化输出先过 schema 校验,事实性内容先过检索比对,只有通过校验的结果才进入主链路。第二步,量化不确定性:同一个输入跑三次,看输出的一致程度,一致性低的任务要么加约束、要么换更强的前置检索,而不是靠调温度硬压。第三步,做成本上限:把 token 预算、单次调用耗时、每日调用量写成配置项,一旦超限就降级到缓存或模板回复,而不是等账单出来才发现。

验证方法上,我习惯先用一小批真实样本做离线回归:准备二十到五十条覆盖主要场景的输入,记录每次的 token 消耗、输出长度、是否通过校验,改动提示词或换模型之前先跑一遍。这套东西不复杂,但能挡住大部分「上线才发现效果倒退」的情况。至于进阶方向,检索增强和工具调用是两条最实际的路——前者把「记忆」从窗口里挪到外部存储,后者把「计算」从概率采样挪到确定性函数,两者都在缩小模型需要凭空编造的空间。

血泪经验是:早期我总觉得提示词写得够细就能解决问题,结果每次模型升级、每次业务加需求,提示词就越滚越长,最后长到自己都维护不动。后来才想明白,提示词是在跟一个概率分布对话,你能做的是缩小它的活动范围,而不是命令它必须怎样。从那以后我每次接一个新模型,都会先拿真实样本跑一遍 token 预算和输出稳定性,再决定要不要把它放进主链路——这份资源里从注意力机制讲到工程接入的那部分内容,正好是建立这套判断力的最短路径,值得存下来慢慢翻。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询