DarkYellowCat's Blog

分享技术与思考

前言

我在通过 omp 使用 gpt plus 订阅时出现了两种报错,分别是 403 unsupported_country_region_territory 和 交换 Token 时网络超时, 最终成功解决问题,这里提供排查思路。

排查思路

虚拟网卡

修改后成功

确保非浏览器应用的所有网络流量都被强制代理。

关闭 ipv6

若环境不支持或未接管 IPv6,导致请求绕过代理直连超时。

  1. 按 Win + R键,输入 ncpa.cpl 并回车打开“网络连接”。

  2. 右键当前正在使用的网卡(Wi-Fi 或以太网),选择 属性。

  3. 在列表中找到 Internet 协议版本 6 (TCP/IPv6),取消勾选,点击 确定。

  4. 验证方法:在命令行输入 ping xxx.com,确认解析出的 IP 不再是 IPv6 地址,然后重试登录。

注入环境变量

在“用户变量”中新建以下两项(端口为实际本地端口):

变量名:HTTP_PROXY,变量值:http://127.0.0.1:7890

变量名:HTTPS_PROXY,变量值:http://127.0.0.1:7890

申明

  1. 用途限制:本文内容仅为个人在技术研究、网络调试与故障排除过程中的经验记录,仅供学习与学术交流使用。
  2. 合规提示:请严格遵守所在国家/地区的相关法律法规,并自觉遵守相关产品与服务提供商的用户协议及服务条款。
  3. 免责条款:文中所述排查步骤基于特定软硬件及网络环境,作者不对方案的通用性、时效性及操作结果作任何明示或暗示的保证。因参考本文操作而引起的系统异常、网络故障、账号封禁或其他连带损失,作者概不承担任何法律与连带责任。

两个对象字段看起来一样,放进 HashSet 后却查不到;或者重写了 equals(),编辑器又提示要重写 hashCode()。这些现象都指向同一个问题:我们给对象定义了新的“相等”含义,却没有同步定义它在哈希集合中的散列码。

默认比较与业务相等

Object.equals() 的默认实现比较的是两个引用是否指向同一个对象。若业务上认为两个不同实例只要具有相同的标识就相等,就需要在类里重写 equals()。

下面用不可变的用户标识作为集合键。两个 UserKey("u-1") 是不同实例,但按标识比较相等:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;

final class UserKey {
private final String id;

UserKey(String id) {
this.id = id;
}

@Override
public boolean equals(Object other) {
if (this == other) return true;
if (!(other instanceof UserKey)) return false;
UserKey that = (UserKey) other;
return Objects.equals(id, that.id);
}

@Override
public int hashCode() {
return Objects.hashCode(id);
}
}

public class HashCodeExample {
public static void main(String[] args) {
Set<UserKey> users = new HashSet<>();
users.add(new UserKey("u-1"));
System.out.println(users.contains(new UserKey("u-1"))); // true
System.out.println("Aa".hashCode() == "BB".hashCode()); // true
}
}

HashSet 先利用散列码定位候选位置,再结合相等比较判断元素是否匹配。若只按 id 重写 equals(),却保留 Object.hashCode(),两个业务上相等的实例可能得到不同散列码,集合查找就会出问题。示例让两个方法都依据同一个 id 字段,因此保持一致。

必须遵守的关系

条件 能推出什么
a.equals(b) 为 true a.hashCode() == b.hashCode() 必须成立
两个散列码相同 不能推出两个对象相等,可能发生碰撞
两个散列码不同 若实现遵守契约,则两个对象不可能相等

"Aa" 和 "BB" 在 Java 中就是散列码相同、内容却不同的例子。普通 hashCode() 是供哈希表使用的整数值,不要求抗碰撞,也不适合当作密码摘要或安全校验值。

同一对象在一次程序运行中,只要参与相等比较的信息没有变化,反复调用 hashCode() 就应得到相同结果;规范不要求不同运行之间的结果一致。因此,不要把一般对象的散列码当作持久身份或数据库主键。

Object.hashCode() 的规范也没有保证返回对象的内存地址。它只要求尽可能让不同对象得到不同整数。旧笔记里“默认散列码就是存储地址”的说法不能作为 Java 的通用规则。

当对象用作集合键时

上例把 id 声明为 final,避免对象进入 HashSet 后再改变用于相等比较和散列计算的值。如果这类值在入集合后发生变化,查找可能落到与插入时不同的位置。编写值对象时,让相等比较涉及的字段保持不变,是最容易维护的做法。

实际重写时还要检查 equals() 本身的规则,例如自反、对称、传递,以及与 null 比较返回 false。不要只看两个示例对象能否比较成功,就认为实现已经满足契约。

接口描述对象对外提供什么能力;抽象类可以为一组相关的子类保存共同状态和实现。

用一个例子看两者如何配合

以下代码用控制台模拟消息发送。调用方只依赖 MessageSender,不需要知道发送器内部如何组织代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
interface MessageSender {
void send(String message);

default void sendIfPresent(String message) {
if (message != null && !message.isEmpty()) {
send(message);
}
}
}

abstract class NamedSender implements MessageSender {
private final String name;

protected NamedSender(String name) {
this.name = name;
}

protected String label() {
return "[" + name + "] ";
}
}

class ConsoleSender extends NamedSender {
ConsoleSender(String name) {
super(name);
}

@Override
public void send(String message) {
System.out.println(label() + message);
}
}

public class InterfaceExample {
public static void main(String[] args) {
MessageSender sender = new ConsoleSender("notice");
sender.sendIfPresent("Hello"); // [notice] Hello
}
}

send() 是接口规定的能力,具体类必须提供实现,除非该类仍是抽象类。sendIfPresent() 是接口的默认方法,调用它时会继续调用具体实现的 send()。NamedSender 则保存名字,并把构造和标签拼接逻辑留给同一类发送器的子类复用。

这个例子把两种机制放在一起展示;如果实际项目只有一个发送器、也没有共享实现的需要,直接让 ConsoleSender implements MessageSender 会更简单,不必专门引入抽象类。

接口不只是抽象方法

接口中的普通方法声明默认是 public abstract,实现时需要使用 public。接口也可以声明 default 方法和 static 方法;Java 9 起还可以使用私有方法辅助复用接口内部逻辑。接口字段始终是 public static final,不能用来存储每个对象各自会变化的状态。

默认方法适合在接口上增加一个对现有实现类也有合理含义的操作,但它不是“随手放公共代码”的地方。两个互不相关的接口若提供了相同签名的默认方法,实现类通常需要自己明确覆盖,不能指望编译器猜选哪一个。

抽象类能做什么

抽象类不能直接实例化,但可以有构造器、实例字段、普通方法和抽象方法。它适合一组关系紧密的类共享状态或部分实现。Java 类只能继承一个类,却可以实现多个接口;因此接口也适合描述不同类都具备的同一种能力。

遇到的问题 优先考虑
调用方只需约定“能做什么”,实现类可能来自不同类层次 接口
一组相关子类要共享实例状态、构造过程或受保护的实现 抽象类
两种需求同时存在 对外暴露接口,再按需要用抽象类复用实现

这只是选择依据,不是必须搭配使用的模板。先定义调用方真正需要的方法,再决定有没有足够的共性值得提取到抽象类。

举例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class ArrayCopyExample {
public static void main(String[] args) {
int[][] original = {{2, 4}, {6, 8}};

int[][] sharedRows = original.clone();
sharedRows[0][0] = 20;

int[][] copiedRows = new int[original.length][];
for (int i = 0; i < original.length; i++) {
copiedRows[i] = new int[original[i].length];
System.arraycopy(original[i], 0, copiedRows[i], 0, original[i].length);
}
copiedRows[0][0] = 99;

System.out.println(original[0][0]); // 20
System.out.println(sharedRows[0][0]); // 20
System.out.println(copiedRows[0][0]); // 99
System.out.println(original == sharedRows); // false
System.out.println(original[0] == sharedRows[0]); // true
System.out.println(original[0] == copiedRows[0]); // false
}
}

Java 的 int[][] 实际上是“元素为 int[] 引用的数组”。可以分两层看:

1
2
original ──> 外层数组 ──> 第一行 int[] {2, 4}
└─> 第二行 int[] {6, 8}

复制外层数组:行仍然共享

original.clone() 创建了新的外层数组,但复制到新外层数组中的元素是行数组的引用。因此 original != sharedRows,而 original[0] == sharedRows[0]。改动 sharedRows[0][0],相当于改动两者共同指向的第一行,所以 original[0][0] 也变成 20。

只调用一次 System.arraycopy(original, ..., another, ...) 复制整个外层数组,效果也是复制行引用;它不会自动递归地复制每一行。

逐行复制:整数元素不再共享

示例中的循环为每一行新建 int[],再把该行的整数复制过去。此时 copiedRows[0] 与 original[0] 是两个数组。给 copiedRows[0][0] 赋值 99,不会改变 original[0][0]。

顺序也重要:程序先通过 sharedRows 把原数组的 2 改成 20,随后才创建 copiedRows。因此复制进去的是 20,最后又被改成 99。分析原练习的结果时,也要按代码执行顺序跟踪每一步,而不能只看最初的初始化值。

这里复制的是 int 这样的基本类型值。若行内存放的是对象引用,逐行复制只会复制这些引用,对象本身仍可能被共享;“复制行”不等于“递归复制了所有对象”。

文件位置:Path 与 File

Path 表示一个文件系统路径,File 是较早的路径抽象。它们表示路径,不等于已经打开了文件,也不要求先创建一个 File 对象才能读写。新代码可以先从 Path 和 Files 入手:

1
2
3
4
5
6
7
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;

Path path = Paths.get("data", "notes.txt");
System.out.println(path.toAbsolutePath().normalize());
System.out.println(Files.exists(path));

相对路径按程序运行时的工作目录解释,不是按 .java 文件所在目录解释。normalize() 会整理路径中的 . 和 ..,但不会因此创建文件。旧代码如果已经使用 File,可以调用 file.toPath() 与 Files API 配合。

输入与输出:方向以程序为准

InputStream 从外部读取字节到程序;OutputStream 把程序中的字节写出去。Files.newInputStream(path) 和 Files.newOutputStream(path) 可以打开文件流。

单字节读取时,read() 返回 0 到 255,到达末尾返回 -1。批量读取时,read(byte[]) 返回本次实际读到的字节数;写出时要传入这个数量,不能每次都把整个缓冲区写出去,否则最后一块可能混入旧数据。

下面是一段完整的文件复制程序。运行前准备好输入文件,执行 java FileCopyExample input.txt copy.txt:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;

public class FileCopyExample {
public static void main(String[] args) throws Exception {
Path source = Paths.get(args[0]);
Path target = Paths.get(args[1]);

try (InputStream in = Files.newInputStream(source);
OutputStream out = Files.newOutputStream(target)) {
byte[] buffer = new byte[8192];
int count;
while ((count = in.read(buffer)) != -1) {
out.write(buffer, 0, count);
}
}
}
}

Files.newOutputStream(target) 默认会创建不存在的目标文件;目标文件已存在时会截断原内容。因此不要把唯一一份重要文件作为测试目标。若只想复制文件,直接用 Files.copy(source, target) 更简洁;这里手写循环是为了看清字节流的工作过程。

为什么使用 try 管理资源

文件流占用系统资源。即使读取或写入途中抛出异常,try 后括号中的流也会在离开代码块时关闭。示例中的输入、输出流会按声明的相反顺序关闭,不需要再手写 finally。

flush() 用于要求输出流将已缓冲的数据继续交给下一层。并非每个输出流都有自己的缓冲区,也不能把 flush() 理解成“数据一定已经安全落盘”。在普通文件复制中,正确关闭流是必需的;只有需要在流保持打开时及时交出缓冲数据,才考虑显式调用 flush()。

字节不是文字

上述程序复制的是原始字节,所以图片、压缩包和文本都能按相同方式处理。但要把文字变成字节或还原成文字时,必须明确字符编码。例如:

1
2
3
4
import java.nio.charset.StandardCharsets;

byte[] data = "学习 Java".getBytes(StandardCharsets.UTF_8);
String text = new String(data, StandardCharsets.UTF_8);

把字节长度、字符数和文件大小混为一谈,容易在中文文本上出错。如果任务本来就是整段文本的读写,还可以直接使用 Files 提供的文本方法,无须为了使用流而手写复制循环。

学习后端接口时,我曾把请求行叫作“状态行”,还以为只有 POST 请求才能带请求体。把一次 HTTP/1.1 交互拆开看,这两个概念就容易分清了。

从一次请求开始

下面的示例请求 /notes/42,响应是一段 JSON。这里展示的是便于阅读的 HTTP/1.1 报文形式;HTTP/2 和 HTTP/3 不使用同样的文本报文格式。

1
2
3
4
GET /notes/42 HTTP/1.1
Host: example.com
Accept: application/json

1
2
3
4
5
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 9

{"id":42}

空行用于分隔头部和消息体。上面的 GET 请求没有消息体;响应体 {"id":42} 按 ASCII 编码是 9 个字节,因此示例中的 Content-Length 为 9。实际长度要按传输的字节计算,不能直接用中文字数代替。

请求:我想做什么

HTTP/1.1 请求由请求行、头部字段和可选的消息体组成:

1
2
3
4
方法 请求目标 HTTP版本
头字段名: 值

可选的消息体

请求行里的 GET 表示获取目标资源,/notes/42 是请求目标。Host 指明目标主机;Accept 表示客户端希望接收的内容类型。发送 JSON 时,常用 Content-Type: application/json 说明消息体的媒体类型。

不要用“GET 没有请求体、POST 一定有请求体”判断报文结构:消息体是否存在与方法名称不是一回事。GET 请求通常不发送内容,且请求内容没有通用定义的语义;POST 可以携带需要服务端处理的数据,也可能发送空内容。具体请求还要遵守所调用接口的约定。

响应:处理结果是什么

HTTP/1.1 响应由状态行、头部字段和可选的消息体组成。状态行中的 200 是状态码,OK 是原因短语;程序应依据状态码处理结果,不应依赖原因短语的固定文字。

状态码 常见含义
200 OK 请求成功,响应内容取决于请求方法
201 Created 已创建资源
204 No Content 请求成功,响应没有内容
301 Moved Permanently 资源被永久重定向
304 Not Modified 条件请求的资源未修改,可继续使用已有副本
400 Bad Request 服务端无法或不愿处理有问题的请求
401 Unauthorized 需要有效的身份凭据
403 Forbidden 服务端理解请求但拒绝处理
404 Not Found 没有找到目标资源
500 Internal Server Error 服务端处理时发生内部错误

状态码首位还可用于快速判断类别:1xx 表示信息,2xx 表示成功,3xx 表示重定向,4xx 表示客户端错误,5xx 表示服务端错误。响应头中的 Content-Type 描述内容类型,Location 常用于说明新资源或重定向目标,Cache-Control 用于表达缓存要求。

为什么不能把 TCP 握手画进每次 HTTP 请求

HTTP 定义请求和响应的语义,连接如何建立属于传输层。HTTP/1.1 和 HTTP/2 通常运行在 TCP 上;一条连接可以承载多个请求,不必每次请求都重新完成 TCP 握手。HTTP/3 则使用基于 UDP 的 QUIC,不能套用“先 TCP 三次握手,再发请求”的流程图。

不同版本的报文编码和传输方式不同,但方法、目标、头字段和状态码这些核心语义可以放在一起理解。初学时先读懂一组 HTTP/1.1 报文,再去了解 HTTP/2 的帧和 HTTP/3 的 QUIC,会更容易区分“请求的意思”和“消息如何传输”。

参考资料

核心原则

范围内选择最小的数据类型

字段类型越小,意味着单行数据和索引记录更紧凑。在相同页大小下,数据库可以容纳更多记录,从而可能提高缓存命中率并减少磁盘 I/O。

辨析

类型声明的的大小,不代表按最大长度占用空间

1
nickname VarChar(20)

VarChar(20) 中的 20 表示最多保存 20 个字符,并不意味着每行都会固定占用 20 个字符的空间。VarChar 通常按照实际内容长度保存数据,同时额外使用 1 或 2 字节记录长度。

如果用户昵称只有 5 个字符,数据库主要保存这 5 个字符编码后的字节,而不是预留完整的 20 个字符空间。实际占用还会受到字符集影响, utf8mb4 中一个字符最多可能需要 4 字节。

不过,字段声明的最大长度仍可能影响:

  • 是否需要使用 1 字节或 2 字节的长度前缀;
  • InnoDB 对最大行长度的检查;
  • 临时表、排序和某些执行阶段的内存估算;
  • 索引键允许的最大长度;
  • 应用驱动和 ORM 对字段的处理方式。

整数类型则是固定长度的:

所以,能用 INT 就不用 BIGINT 。

但也不要取极限值,字段类型先由业务语义和生命周期确定,再讨论字节数。过窄的类型会把正常增长变成高风险 DDL。

选择简单的数据类型

复杂的数据类型不利于 MySQL 优化器 进行分析和优化。

优先使用能够直接表达业务语义的原生类型,避免把结构化数据编码成需要额外解析的字符串、二进制对象或 JSON。

1
优先级 整型 > date,time > Char,VarChar > blob

减少 NULL

  1. 引入三值逻辑

SQL 中的布尔判断不仅有 TRUE 和 FALSE,还存在 UNKNOWN。

下面的查询不能找到空值:

1
2
3
SELECT *
FROM customer
WHERE phone = NULL;

必须使用:

1
2
3
SELECT *
FROM customer
WHERE phone IS NULL;

NULL 还会影响 AND、OR、NOT、IN 和 NOT IN 等表达式。如果开发者不了解三值逻辑,容易编写出结果不符合预期的查询。

  1. 影响聚合结果

大部分聚合函数会忽略 NULL:

1
2
3
4
SELECT
COUNT(*) AS total_rows,
COUNT(phone) AS known_phone_rows
FROM customer;

COUNT(*) 统计所有行,而 COUNT(phone) 只统计 phone 不为 NULL 的行。两者含义不同,报表和统计代码必须明确区分。

  1. 增加应用判断分支

允许 NULL 后,应用、ORM、序列化接口和业务代码都需要正确处理可空状态。如果字段实际上不存在“未知”或“不适用”的业务含义,允许 NULL 只会增加无意义的分支。

  1. 影响索引选择性和执行计划

InnoDB 可以为允许 NULL 的列建立索引,IS NULL 查询也可能使用索引。因此,“可空列不能使用索引”是错误的。

真正影响执行计划的是数据分布。例如,如果一列 90% 的记录都是 NULL,查询 IS NULL 需要返回大部分数据,优化器就可能认为全表扫描比索引访问更划算。

  1. 产生少量存储开销

InnoDB 通常使用位图记录哪些可空列是 NULL。若索引记录中包含 (N) 个可空字段,NULL 位图大约需要:

[ \operatorname{CEILING}(N / 8) ] 字节。

原因

版本差异

同一条设计建议在三个版本中可能有不同前提。

能力或默认行为 MySQL 5.7 MySQL 8.0 MySQL 8.4
默认字符集 latin1 utf8mb4 utf8mb4
默认排序规则 latin1_swedish_ci utf8mb4_0900_ai_ci utf8mb4_0900_ai_ci
原生 JSON 支持 支持 支持
JSON 路径索引 生成列 生成列或函数索引 生成列或函数索引
函数索引 不支持 8.0.13 起支持 支持
JSON 多值索引 不支持 8.0.17 起支持 支持
JSON 局部更新 不支持优化 有条件支持 有条件支持
整数显示宽度 可用 8.0.17 起弃用 弃用
FLOAT DOUBLE AUTO_INCREMENT 可用但不建议 弃用 8.4.0 起不支持

默认值变化的实际影响

MySQL 5.7 默认使用 latin1 和 latin1_swedish_ci,而 8.0 与 8.4 默认使用 utf8mb4 和 utf8mb4_0900_ai_ci。若 DDL 没有显式指定字符集和排序规则,同一份建表语句可能得到不同列定义。比较、排序、唯一索引冲突和索引长度都可能随之改变。升级方案应把隐式默认值改成显式定义。

InnoDB 存储基础

字段类型的物理成本不能只看类型表。InnoDB 将表组织为以主键为序的聚簇索引。聚簇索引叶子记录保存完整行,二级索引叶子记录保存二级索引列和对应的主键列。变长列可能保存在索引页内,也可能通过 20 字节指针引用溢出页。

主键宽度的放大效应

假设一张表有五个二级索引,把主键从 4 字节 INT 改成 8 字节 BIGINT,会给每条二级索引记录增加最多 4 字节的主键负担。实际空间还受记录头、页填充率、压缩和主键列是否已包含在索引中影响,但方向明确。选择主键类型时应把所有二级索引计入成本。

行格式决定长列位置

COMPACT 行格式会在记录内保留长变长值的前 768 字节,再把其余部分放到溢出页。DYNAMIC 行格式可以把长值完整放到行外,并在聚簇记录中保留 20 字节指针。是否外置取决于页面能否容纳整行,而不是只由列名是 VarChar 还是 TEXT 决定。DYNAMIC 同时支持更大的索引键前缀。

类型 基本存储量 设计提示
TINYINT 1 字节 适合小范围代码和值域明确的状态
SMALLINT 2 字节 适合中小范围计数或代码
MEDIUMINT 3 字节 节省 1 字节但生态使用较少
INT 4 字节 常用整数和中等规模标识
BIGINT 8 字节 适合大规模标识和大范围累计值
DATE 3 字节 只保存日期
TIMESTAMP 4 字节加小数秒 带时区转换且存在 2038 范围边界
DATETIME 5 字节加小数秒 保存日历日期时间
VarChar 实际字节加 1 或 2 字节 前缀长度由最大可能字节数决定
DECIMAL 按 9 位十进制数分组 精度和小数位分别影响存储

整数类型

按生命周期估算取值域

整数类型应覆盖字段在整个生命周期内的最大和最小值。估算时不能只看当前行数,还要加入数据保留年限、业务增长、历史导入、分库编码、ID 生成策略和计算中间值。对于主键,范围不足后的扩容通常需要修改所有引用列,并可能触发表重建。

类型 字节 有符号范围 无符号范围
TINYINT 1 -128 到 127 0 到 255
SMALLINT 2 -32768 到 32767 0 到 65535
MEDIUMINT 3 -8388608 到 8388607 0 到 16777215
INT 4 -2147483648 到 2147483647 0 到 4294967295
BIGINT 8 约负 9.22e18 到正 9.22e18 0 到约 1.84e19

整数显示宽度不是容量

INT(4) 和 INT(11) 都占 4 字节,也具有相同取值范围。括号中的数字是历史显示宽度,不能限制输入位数。与 ZEROFILL 配合时,它可以影响格式化显示,但该能力从 MySQL 8.0.17 起被弃用。需要前导零的业务编号应作为字符串存储,或者在展示层格式化。

Unsigned 的取舍

Unsigned 可以把负数区间换成更大的正数区间,但它也扩展了 MySQL 与其他数据库、语言类型和 ORM 之间的差异。主外键及所有关联列应保持整数宽度和符号属性一致。对金额、测量值和 DECIMAL 使用 Unsigned 的做法在 8.0.17 起被弃用,更清晰的业务下界可由 CHECK 或应用验证表达。

布尔值和状态值

MySQL 的 BOOL 和 BOOLEAN 是 TINYINT(1) 的同义形式,并不会自动阻止写入 2 或负数。若字段确实只允许两个状态,应在 8.x 使用 CHECK 约束,或者通过应用和测试保证输入。若业务存在未知、未处理和失败等额外状态,则不应强行压成布尔值。

精确数和近似数

DECIMAL 保存十进制定点精确值,FLOAT 和 DOUBLE 保存二进制浮点近似值。两类类型的差异不仅体现在展示小数位,还体现在表达式传播、聚合、相等判断和跨平台结果。

金额和精确计算

金额、余额、税额和必须按十进制规则结算的值通常应使用 DECIMAL。DECIMAL(M,D) 中 M 是总有效位数,D 是小数位数。设计时要根据最大金额、币种精度、税率计算和中间结果确定范围,不能习惯性使用 DECIMAL(10,2)。

存储规则 DECIMAL 把整数部分和小数部分分别分组,每 9 个十进制数字使用 4 字节,剩余数字按组占 0 到 4 字节。符号不单独增加一个完整字节。

写入规则 小数位超过 D 时会舍入。整数位超出范围时,严格模式通常报错,非严格模式可能截到合法边界并产生警告。

浮点数的适用范围

FLOAT 和 DOUBLE 适合测量值、统计模型、图形和科学计算等允许近似误差的场景。不要用浮点列直接保存需要逐分一致的金额,也不要依赖直接等值比较。任何近似值参与表达式后,MySQL 会使用浮点运算;字符串参与数值表达式时也可能转换为 DOUBLE,使原本精确的计算变成近似计算。

1
2
3
4
5
6
7
SELECT 0.1 + 0.2 = 0.3 AS equal_result;

CREATE TABLE number_test (
f FLOAT,
d DOUBLE,
amount DECIMAL(20, 6)
);

日期和时间类型

含义 常用类型 说明
生日和结算日 DATE 只有日期,不应受时区转换影响
门店营业时间和预约日历值 DATETIME 按所在地规则解释,可另存时区标识
创建时间和事件发生时刻 TIMESTAMP 或 UTC DATETIME 需要统一时区策略和驱动配置
耗时和剩余时间 整数单位或专门建模 不要把持续时长误当作日期时间

TIMESTAMP 和 DATETIME

TIMESTAMP 写入时按会话时区转换为 UTC,读取时再从 UTC 转回会话时区。DATETIME 保存写入的日历字段,不做这种转换。三个目标版本的官方文档都把 TIMESTAMP 上限列为 2038 年 1 月 19 日 03:14:07 UTC,而 DATETIME 支持到 9999 年。因而 TIMESTAMP 不能仅凭空间较小而成为默认选择。

小数秒精度

TIME、DATETIME 和 TIMESTAMP 的小数秒精度会增加存储。精度为 1 到 2 位增加 1 字节,3 到 4 位增加 2 字节,5 到 6 位增加 3 字节。只有在排序、去重或审计确实需要时才应使用微秒。

零日期和 SQL 模式

把 0000-00-00 或 1970-01-01 当作未发生的哨兵值会把状态混入时间字段,并使范围查询和统计产生假数据。允许空值时应使用 NULL;不允许空值时应把业务状态拆成独立列。不同 sql_mode 对无效日期和零日期的处理存在差异,迁移前必须测试。

1
2
3
4
5
6
SET time_zone = '+08:00';
INSERT INTO temporal_test(ts_value, dt_value)
VALUES ('2026-09-16 10:00:00', '2026-09-16 10:00:00');

SET time_zone = '+00:00';
SELECT ts_value, dt_value FROM temporal_test;

字符和长文本类型

VarChar 长度的含义

VarChar(N) 中的 N 是字符数,而不是字节数。实际数据占用取决于字符编码后的字节长度,并额外使用 1 或 2 字节保存长度。是否需要 2 字节长度前缀取决于列的最大可能字节数。VarChar(255) 使用 utf8mb4 时最大可能达到 1020 字节,因此 255 并不是通用的单字节分界或性能甜点。

Char 的适用场景

Char 适合真正定长且尾部空格没有业务意义的值,例如某些固定长度代码。对于变长字符集,InnoDB 的实际存储会做专门处理,不能简单按 N 乘以 4 估算所有情况。尾部空格在存储、读取和比较中的行为还会受类型及排序规则的 PAD 属性影响。

二进制标识和值

十六进制散列、UUID 和原始字节若不需要语言学比较,应评估 BINARY 或 VARBINARY。将 16 字节 UUID 存成 Char(36) 会增加聚簇和二级索引宽度。若系统依赖按时间有序写入,还需要评估 UUID 版本和字节排列,不能只替换字段类型。

TEXT 与 VarChar

TEXT 与 VarChar 的关键差异主要在最大长度、索引方式和某些 SQL 操作限制。TEXT 索引通常必须指定前缀长度,前缀索引可能无法提供完整唯一性和区分度。使用 DYNAMIC 行格式时,长 VarChar 和 TEXT 都可能完整外置;短 TEXT 值仍可能保存在行内。因此二者不能用行内和行外的简单二分法选择。

索引长度和 191 字符误区

utf8mb4 索引只能建立 191 字符是带条件的旧结论。COMPACT 和 REDUNDANT 行格式的索引键前缀上限为 767 字节,按每字符最多 4 字节计算得到 191。DYNAMIC 和 COMPRESSED 在默认 16KB 页下支持 3072 字节。页大小降低时,上限会按比例降低。

字符串字段决策问题

  • 字段是自然语言文本、机器标识,还是原始二进制值。

  • 最大长度来自业务规则,还是为了省事随意填写。

  • 字段是否参与等值查询、范围查询、排序、分组或唯一约束。

  • 尾部空格、大小写、重音和 Unicode 补充字符是否具有业务意义。

  • 查询是否经常只读其他列,还是每次都需要返回大字段。

JSON 类型

MySQL 原生 JSON 会验证写入内容并转换为便于按路径访问的二进制格式。二进制编码包含元数据和查找结构,因此空间通常接近文本表示并增加一定开销。JSON 的主要价值是验证、路径函数和索引能力,而不是保证压缩。

适合和不适合的字段

JSON 适合稀疏、变化快并且大部分不参与关系操作的扩展属性,例如第三方原始响应、设备差异化配置和少量可选元数据。以下字段通常应拆成普通列。

  • 高频过滤、排序、分组和关联字段。

  • 需要外键、NOT NULL、唯一约束或严格类型约束的核心属性。

  • 更新频繁且可以稳定建模的小字段集合。

  • 需要由 BI、数据仓库和多个语言客户端稳定消费的公共字段。

三个版本的索引路径

MySQL 5.7 不能直接为整个 JSON 建普通索引,常见方式是创建提取标量值的生成列并对生成列建索引。MySQL 8.0.13 起支持函数索引,可直接为表达式值建索引。MySQL 8.0.17 起支持面向 JSON 数组的多值索引。多值索引不能覆盖所有普通索引能力,并有排序、外键和在线创建限制。

1
2
3
4
5
6
7
8
9
10
11
-- MySQL 5.7 兼容方案
ALTER TABLE orders
ADD customer_id BIGINT UNSIGNED
GENERATED ALWAYS AS (
CAST(JSON_UNQUOTE(JSON_EXTRACT(ext, '$.customer_id')) AS UNSIGNED)
) VIRTUAL,
ADD INDEX idx_customer_id (customer_id);

-- MySQL 8.0.13 及以上
CREATE INDEX idx_customer
ON orders ((CAST(ext->>'$.customer_id' AS UNSIGNED)));

函数索引中的类型和排序规则

JSON_UNQUOTE 返回的字符串类型和排序规则可能与 CAST 后的表达式不同。若查询表达式与索引表达式在类型、长度或排序规则上不一致,优化器可能无法使用函数索引。设计函数索引时应把查询 SQL、CAST 类型和 COLLATE 一起固定,并用 EXPLAIN 验证。

局部更新的边界

MySQL 8.x 可以在满足条件时对 JSON 做局部原地更新,但不能将其概括为修改 JSON 永远不重写文档。被更新列必须是 JSON,语句需要使用 JSON_SET、JSON_REPLACE 或 JSON_REMOVE 等受支持函数,新值和可用空间还必须符合优化条件。直接把整份 JSON 赋给列不属于局部更新。

NULL 设计

NULL 表达未知

NULL 与空字符串、零和空 JSON 对象不同。NULL 通常表示未知、不适用或尚未发生。若业务需要区分未知和明确为空,就必须保留这种差异;用空字符串统一替代 NULL 会丢失信息。

1
2
3
4
5
6
SELECT * FROM customer WHERE phone IS NULL;
SELECT * FROM customer WHERE phone = '';

SELECT COUNT(*) AS rows_total,
COUNT(phone) AS known_phone_count
FROM customer;

三值逻辑

任何值与 NULL 使用等号比较都不会得到 TRUE,包括 NULL 自身。搜索空值必须使用 IS NULL 或 IS NOT NULL。聚合函数通常忽略 NULL,而 COUNT(*) 计算行数。复杂条件中的 UNKNOWN 还会影响 AND、OR 和 NOT 的结果,查询编写和测试必须覆盖空值。

存储成本

在 InnoDB COMPACT 和 DYNAMIC 记录中,可空列由位图记录。若索引记录中有 N 个可空列,位图使用 CEILING(N/8) 字节。可空变长列为 NULL 时不保存数据内容。因而每个 NULL 浪费一个字节并不准确。NOT NULL 的首要理由应是业务约束,而不是未经测量的性能假设。

索引和选择性

InnoDB 可以为允许 NULL 的列建立索引,IS NULL 也可能使用索引。是否使用取决于 NULL 比例、统计信息、查询返回列和成本模型。若绝大部分行都是 NULL,索引可能很小且有效;若查询需要返回大部分表,优化器也可能选择全表扫描。

字符集和排序规则

字符集决定字段能编码哪些字符以及最大字节数,排序规则决定字符串如何比较和排序。字段长度、索引大小、唯一约束和查询结果都可能同时受二者影响。

使用 utf8mb4

MySQL 传统的 utf8 最多使用 3 字节,无法保存 Unicode 补充平面字符。utf8 已作为 utf8mb3 的弃用别名。新系统通常应显式使用 utf8mb4,升级旧系统时要同时检查索引长度、连接字符集和实际脏数据。

字段选择框架

业务数据 首选候选 需要确认的问题
主键 INT 或 BIGINT 增长上限 生成策略 二级索引数量
状态码 TINYINT SMALLINT 或 VarChar 可读性 状态扩展 外部协议
金额 DECIMAL 或整数最小单位 最大值 币种精度 舍入规则
事件时刻 TIMESTAMP 或 UTC DATETIME 时区 2038 范围 驱动行为
日历时间 DATETIME 是否需要保存时区标识和夏令时规则
短文本 VarChar 最大字符数 字符集 比较规则 索引
长文本 TEXT 系列 读取频率 前缀索引 排序分组
固定二进制值 BINARY 或 VARBINARY 长度 是否需要语言学比较
扩展属性 JSON 查询路径 约束 更新频率 版本能力
未知或未发生 允许 NULL 业务是否需要区分空值和未知

常见误区核查

常见说法 更准确的结论
字段越小查询越快 更窄记录可能提高页密度和缓存命中率,收益仍取决于索引、访问模式和数据规模。
INT(11) 只能保存 11 位 11 是历史显示宽度,不改变 4 字节存储和整数范围。
VarChar(255) 性能最好 没有通用分界。最大字节数、行长度和查询用途更重要。
Char 永远按最大长度占空间 InnoDB 对变长字符集和不同 ROW_FORMAT 有专门存储规则。
TEXT 永远保存在行外 是否外置取决于行格式、值长度和整行大小。短 TEXT 可能行内。
TIMESTAMP 永远优于 DATETIME 二者的时区语义和范围不同,空间只差 1 字节。
金额用 DOUBLE 再 ROUND 就行 中间步骤已经可能产生近似误差,ROUND 不能恢复精确原值。
NOT NULL 一定更快 应先满足业务约束。可空列使用位图,性能需按查询和分布测试。
可空列不能使用索引 InnoDB 可索引 NULL,IS NULL 是否走索引由成本决定。
MySQL utf8 是完整 UTF 8 传统 utf8 是 utf8mb3,不能保存全部 Unicode。
utf8mb4 索引最多 191 字符 该数字来自旧行格式的 767 字节限制,不是所有配置的上限。
8.0 修改 JSON 都是局部更新 只有满足函数和空间等条件的更新才能使用局部优化。

迁移和上线检查

现状盘点 SQL

1
2
3
4
5
6
7
8
9
10
11
12
13
SELECT
TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME,
COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT,
CharACTER_SET_NAME, COLLATION_NAME
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'your_database'
ORDER BY TABLE_NAME, ORDINAL_POSITION;

SELECT
TABLE_SCHEMA, TABLE_NAME, ENGINE, ROW_FORMAT,
TABLE_COLLATION, DATA_LENGTH, INDEX_LENGTH
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'your_database';

上线策略

字段类型修改可能引发表重建、长事务、复制延迟和额外临时空间。上线方案应明确所需 DDL 算法、锁级别、磁盘峰值、暂停条件和回滚路径。对大表,优先使用影子表迁移、双写校验或经过验证的在线 DDL 工具,并在真实数据分布上演练。

综合改造示例

下面的旧表可以正常存数据,但多个字段混淆了类型语义。

1
2
3
4
5
6
7
8
9
CREATE TABLE orders_bad (
id VarChar(32),
status VarChar(255),
amount FLOAT,
created_at VarChar(30),
paid_at DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00',
customer_name VarChar(255) CharACTER SET utf8,
attributes LONGTEXT
);

在假设订单量需要 BIGINT、金额保留两位、时间表示事件时刻、扩展属性结构不稳定的前提下,可以改为以下定义。

1
2
3
4
5
6
7
8
9
10
11
12
CREATE TABLE orders (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
status TINYINT UNSIGNED NOT NULL,
amount DECIMAL(18, 2) NOT NULL,
created_at TIMESTAMP(6) NOT NULL,
paid_at TIMESTAMP(6) NULL,
customer_name VarChar(100)
CharACTER SET utf8mb4
COLLATE utf8mb4_0900_ai_ci NOT NULL,
attributes JSON NULL,
PRIMARY KEY (id)
) ENGINE=InnoDB ROW_FORMAT=DYNAMIC;

参考资料

0.《高性能 MySQL(第三版)》

1. MySQL 5.7 Reference Manual Data Type Storage Requirements

2. MySQL 8.0 Reference Manual Data Type Storage Requirements

3. MySQL 8.4 Reference Manual Data Type Storage Requirements

前言

从这一章开始,就和使用 Agent 关联比较大了。

我认为需要关注的几个点: token注意力机制、上下文的构成、上下文的运行机制

alt text

上下文:决定 Agent 能力上限的关键

通用模型并不知道具体项目的架构、业务规则和团队约定。以 Coding Agent 为例,代码库结构与规范、Git 和审查流程、运行环境信息,都是完成“修复这个 bug”所需的最低背景;缺少这些信息,模型可能写出语法正确却无法融入项目的代码。因此,上下文工程不是简单地往 Prompt 里塞更多文字,而是系统性地组织 Agent 在每个决策点真正需要的观察、历史和约束。上下文质量足够高时,中等模型也可能胜过信息匮乏下的顶级模型。

消息的角色

  • system:系统提示词。由开发者编写,定义 Agent 的身份、行为规则、约束条件。模型将其视为最高优先级的指令。整个对话过程中通常只有一条,放在消息列表的最前面。
  • user:用户消息。来自终端用户的输入,是 Agent 需要响应的请求。
  • assistant:助手消息。模型之前的回复,包括文本回复和工具调用请求。在多轮对话中,之前的 assistant 消息会被放回消息列表,让模型“记住”自己说过什么。
  • tool:工具结果。Agent 框架执行工具后,将结果以 tool 角色的消息送回给模型。每条 tool 消息通过 tool_call_id 与对应的工具调用请求关联。

上下文的调用

依旧查询天气

第一次

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// ═══ Request constructed by the Agent framework (1st call) ═══
{
"model": "Qwen3-0.6B",
"messages": [
{
"role": "system", // ← Written by developer
"content": "You are a helpful assistant. Use the provided tools to get real-time
information when needed."↪
},
{
"role": "user", // ← User input
"content": "What's the current time and weather in Vancouver?"
}
],
"tools": [ // ← Tools defined by developer
{
"type": "function",
"function": {
"name": "get_current_time",
"description": "Get the current date and time in a specific timezone",
"parameters": {
"type": "object",
"properties": {
"timezone": { "type": "string", "description": "Timezone name, e.g.
America/Vancouver" }
}
}
},
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the current weather for a specific city",
"parameters": {
"type": "object",
"properties": {
"city": { "type": "string", "description": "City name" },
"unit": { "type": "string", "enum": ["celsius", "fahrenheit"] }
}
}
}
}
]


第二次

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// == Request constructed by the Agent framework (2nd call) ==
{
"model": "Qwen3-0.6B",
"messages": [
{
"role": "system", // ← Same as 1st call
"content": "You are a helpful assistant. Use the provided tools to get real-time
information when needed."
},
{
"role": "user", // ← Same as 1st call
"content": "What's the current time and weather in Vancouver?"
},
{
"role": "assistant", // ← Model output from 1st call,
included verbatim
"content": null,
"tool_calls": [
{ "id": "call_abc123", "function": { "name": "get_current_time", "arguments":
"{\"timezone\": \"America/Vancouver\"}" } },
{ "id": "call_def456", "function": { "name": "get_weather", "arguments":
"{\"city\": \"Vancouver\", \"unit\": \"celsius\"}" } }
]
},
{
"role": "tool", // ← Generated by Agent framework (tool
execution result)
"tool_call_id": "call_abc123",
"content": "{\"timezone\": \"America/Vancouver\", \"datetime\":
\"2025-09-13T05:18:47\", \"day_of_week\": \"Saturday\"}"
},
{
"role": "tool", // ← Generated by Agent framework (tool
execution result)
"tool_call_id": "call_def456",
"content": "{\"city\": \"Vancouver\", \"temperature\": 13.2, \"unit\": \"celsius\",
\"conditions\": \"clear\", \"humidity\": 93}"
}
],
"tools": [ ... ] // ← Same tool definitions as above,
omitted
}
  1. 第二次请求包含了第一次的全部对话历史——system 消息、user 消息、第一次的 assistant 回复(包含工具调用),以及新增的 tool 结果。这就是前面所说的“每次调用都是无状态的”:模型不会“记住”上一次的对话,Agent 框架必须每次都把完整历史送回去。
  2. 第一次的 assistant 消息被原样放回消息列表——这让模型能“看到”自己之前做了什么决策。
  3. tool 消息通过 tool_call_id 与对应的工具调用关联——模型据此知道哪个结果对应哪个调用。

这里我重点关注的是:下一次请求会包含上一次的对话历史。这意味着:随着对话次数增多,可能你只是问一个小问题,但也会带上之前所有的对话历史,这也是上下文窗口不足的主要原因。对codex来说,过长的上下文会触发上下文压缩机制,但这也会导致 codex 失去对历史信息的精度。所以,要妥善处理上下文的长度,及时开启新的对话。

我一般会在上下文达到 70% 以上时告诉codex:由于上下文长度原因,我需要重新开启一个对话窗口,总结本次任务的进度并生成可直接交付给下个窗口的命令。

我学到了一种新的更好用的方式,更新一下:

在系统提示从中让 agent 维护一份 progress.md ,里面记录 agent进行的所有操作,并在根目录中维护一份 AGENT.md ,存在核心规则和重要记忆。

KV Cache

解释:

算是一种缓存机制:把前文的中间计 算结果缓存下来,下一轮只需要计算新增 token 的部分。前提是要复用的上下文 token 前缀保持不变——若 token 序列从某个位置开始不同,首个不同 token 及其后的 KV 状态需要重新计算;此前位置的 KV 状态不受这次改动 影响。

假设上下文有 4 个 token [A, B, C, D],模型正要生成第 5 个 token E。注 意力的核心操作是:E 的查询向量(Query)与所有已有 token 的键向量(Key)做点积来计算匹配度(点积的直 观含义见实验 2-2),再根据匹配度对所有 token 的值向量(Value)加权求和,得到 E 的输出表示。 不使用 KV Cache 时,每生成一个新 token 都要从头计算前面所有 token 的 K、V 向量:生成 E 时要计算 5 组 K、V,生成第 6 个 token 时要计算 6 组⋯⋯到第 N 个 token 时要计算 N 组,总计算量与 N² 成正比。 使用 KV Cache 时,A、B、C、D 的 K、V 向量算过一次后就缓存起来。生成 E 时,只需要计算 E 自身的 K、V,然后与缓存中的 4 组一起完成注意力计算。需要注意的是,KV Cache 省去的是历史 token 的 K、V 投影 重算,使每步解码不必重算整个前缀;但每个新 token 的注意力计算仍要遍历全部缓存的 K、V,计算量随上下文 长度线性增长——这正是长上下文解码越来越慢、KV Cache 的显存与带宽成为推理瓶颈的原因。

Chat Template

token 序列(如 <|im_start|>、 <|im_end|> 等特殊标记)看起来与前面 API 的 JSON 格式很不一样。这是因为 API 层面的结构化消息需要被转 换为模型能理解的线性 token 流——负责这个转换的就是 Chat Template(聊天模板)。

对于 Agent 开发者来说,你不需要手动编写或修改 Chat Template——API 服务端会自动处理。但理解它的 存在对 Agent 开发有两个实用价值: 第一,解释了为什么必须使用标准 API 格式。如果开发者绕过 API、自行拼接消息(比如把工具结果作为普 通 user 消息而非 tool 类型传递),Chat Template 会误将工具响应识别为新的用户查询,导致模型的思维链保留 机制被破坏。 以 Qwen3 的 Chat Template 为例:模型在多轮工具调用中,会把之前的内部思考过程( 标签内的内 容)保留下来,像草稿纸上的推导步骤,确保思路的连贯性。但当 Chat Template 检测到新的用户查询时,会默认 “用户换了个话题”,于是清理之前的思考过程重新开始。问题在于,如果工具结果被错误地标记为用户消息,就会误 触发这种清理——相当于模型正算到一半,草稿纸被人收走了,只能从头再来,严重影响多步思考的连贯性

第二,解释了 KV Cache 为什么对前缀如此敏感。Chat Template 将 system 消息和工具定义转换为固定的 token 序列放在最前面。这些 token 的键值对(Key-Value pairs)被缓存后可以跨请求复用。但如果前缀中某个 token 发生变化——哪怕只是系统提示词里多了一个空格——首个不同 token 及其后的缓存就无法复用。

提示词工程

核心对象是系统提示词(System Prompt)——API 消息列表中 role: “system” 的消息。

语气与风格:系统提示词的“人格”

语气和风格的设计是提示工程中最容易被忽视,却又深刻影响用户体验的部分。例如,可以要求“You MUST answer concisely with fewer than 4 lines”(你必须简洁地回答,不超过 4 行);在无法完成任务时,则要求“keep your response to 1-2 sentences”(把回复控制在 1-2 句话),并且“不要解释为什么不能做某事”。这种设计避免了 Agent 陷入冗长的自我辩护。大写字母(如“NEVER do X”)比“Please avoid doing X”更能引起模型的“注意”,但 过度使用会导致效果被稀释,应保留给真正关键的约束。

结构化提示:系统提示词的“格式”

现代大语言模型对结构化输入展现出显著的敏感性,这源于训练数据中包含大量的结构化内容。XML 标签的 使用遵循层次化原则,其标签名称本身就携带语义信息—— 能立即告诉模型这是工作目录 信息,而纯文本格式“当前目录:/Users/project/src”则需要模型做额外的思考来理解冒号前后的关系。 Markdown 在保持可读性的同时提供了轻量级的结构,特别适合组织层次化的指令和信息。XML 和 Markdown 配合使用,可以形成一种双层结构:XML 负责机器可解析的精确语义,Markdown 负责人机共读的组织逻辑。

流程驱动 vs 规则堆砌:系统提示词的“组织方式”

流程设计让模型在任何时刻都能清楚地知道自己处于哪个阶段、当前步骤的目标是什么、完成后该进入哪 个步骤。当遇到异常时,模型可以根据当前所处的阶段确定处理方式,而不是遍历所有规则去寻找匹配项。

Few-shot

示例(few-shot examples)是系统提示词中另一类重要内容。当期望的输出难以用规则精确 描述时——比如特定风格的文案、结构化报告的格式、客服回复的语气分寸——与其堆砌冗长的文字定义,不如直 接给出两三个高质量的输入-输出示例。模型会凭借上下文学习能力从示例中“临时学会”这些模式,其效果往往胜过 等量篇幅的抽象规则。反过来,对于模型本来就擅长、规则又容易说 清的任务,示例只是浪费 token。 工程上有两个决策点。第一,示例放在哪里:放在系统提示词中,示例成为静态前缀的一部分,对所有请求生 效;也可以伪造一组 user/assistant 消息放在首轮对话中,适合按会话类型选用不同示例集的场景。第二,示例对 KV Cache 前缀稳定性的影响:无论放在哪个位置,示例都处于上下文靠前的区域,一旦确定就应当保持字节级稳 定——如果按请求动态检索“最相关”的示例,等于每次都改写前缀,缓存会持续失效。因此生产系统通常为每类任 务准备固定的示例集,而不是逐请求挑选。

提示注入:上下文安全的核心威胁

Agent 会处理网页、邮件和文档等外部内容,这些内容可能夹带伪装成指令的恶意文本,例如要求 Agent 忽略既有规则、泄露对话历史或执行文件写入。提示注入比普通聊天中的越狱更危险,因为 Agent 具备真实的工具调用能力;每一个读取外部内容的工具都可能成为注入入口。

上下文层的防御重点是区分“指令”和“数据”:对外部内容使用明确的来源标记(如 <external_content source="webpage">),严格使用 system/user/assistant/tool 角色传递消息,并把输入清洗作为辅助措施。第三方 Skill 和状态栏同样可能成为新的注入面,所以只能把这些措施视为第一道防线,高风险操作仍需权限控制、沙盒隔离和独立确认。

Skills

出现原因

随着 Agent 覆盖的业务场景越来越多,系统提示词会不断膨胀——客服场景的退款规则、编程场景的代码规 范、文档场景的格式要求⋯⋯全部塞进一个提示词,会带来两个问题:

  1. 浪费 token:大部分内容与当前任务无关
  2. 注意力被稀释:上下文中无关信息过多会稀释模型对关键内容的注意力

渐进式披露机制

alt text

构成

元数据

每个 Skill 必须包含一个 SKILL.md 文件,开头是 YAML frontmatter(即文件顶部用 — 分隔的元数据块,类似书籍的版权页),包含 name 和 description 两个字段。目录应在主体正文加载前对 Agent 可见,使它能够先判断当前任务是否需要某项能力,而不必为所有能力支付完整的上下文成本。不同运行时可以把 目录放在不同的上下文层,目录的共同作用是提供可发现性,而不是承载完整的领域流程。

核心流程

当 Agent 判断某个任务需要特定的 Skill 时,运行时才加载完整的 SKILL.md。Claude Code 会在调用位置把 Skill 指令作为 user message 加入会话;采用文件读取或专用激活工具的其他运行时,也 可以把内容作为 tool result 返回。以 PPTX Skill7 为例,其中包含处理 PowerPoint 文件的核心流程:如何通过 markitdown(Microsoft 开源的文档转 Markdown 工具)提取文本,如何解压 PPTX 文件访问原始的 XML 结构, 以及关键文件的路径约定。

细则

通过文件引用深入到更详细的子文档。主文件引用了 html2pptx.md(通过 HTML 模板创建 PowerPoint 的详细工作流)、reference.md(格式技术细节)等。Agent 会根据具体的需求选择性地深入阅读相关 的子文档。

Agent 状态栏

当对话变长时,模型需要反复从原始轨迹中统计“当前做到哪一步、某个工具调用了几次、还剩哪些约束”。可以在轨迹末尾追加一段由代码维护的状态栏,集中记录当前阶段、任务进度、工具计数、失败原因和环境信息,让模型直接检索这些元信息,而不必每次遍历全部历史。状态栏应放在动态消息区域,不要写入系统提示词,否则会破坏稳定前缀和 KV Cache。

状态栏是对原始历史的有损摘要,模型通常会直接相信其中的数字,因此应优先由程序确定性维护,必要时再逐项抽取后汇总。它能节省上下文并减少重复推理,但不能替代所有原始记录;涉及状态栏未记录的维度时,仍需保留或回溯原始证据。

上下文压缩策略

上下文压缩不只是为了避免窗口溢出,也能减少成本和延迟、降低无关信息造成的“上下文腐化”,并把模型需要反复归纳的结论提前整理成可直接检索的知识。生产系统通常在接近预算阈值时批量压缩旧的工具结果,保留决策、约束、失败原因和引用;系统提示词与核心工具定义尽量保持不变,以保住 KV Cache 的静态前缀。

压缩策略应遵循三点:信息价值并不均匀,关键事实和决策优先保留;摘要不能丢失时间、主体和因果等语义完整性;压缩内容要与当前任务相关,检索、分析和创作任务的保留重点并不相同。噪声可以直接删除,重要轨迹则可分层摘要并保留来源。

另一个常被忽视的办法是“隔离优于压缩”:让子 Agent 在独立上下文中完成大范围搜索,只把结论性摘要回传主 Agent。这样中间过程不会污染主上下文,也不会破坏主 Agent 的缓存,但子任务描述必须足够明确,且返回结果要保留可核查的依据。

0%