首页 文章 精选 留言 我的

精选列表

搜索[Spring Framework],共10000篇文章
优秀的个人博客,低调大师

《Spring Mongo实战》第3招 别把表直接搬过来

凌晨两点,商品详情页的接口又超时了。前台页面要读商品、SKU、属性、图片、分类五个地方,代码里塞了五次查询和一堆拼装逻辑,比原来 MySQL 的 JOIN 还难维护。 我是老李,做后端十几年,最近接手了一个把 MySQL 商品中心迁到 MongoDB 的项目。团队一开始的做法非常直接:把原来的 `product`、`sku`、`attribute`、`image`、`category` 五张表,一张表建一个 Collection,字段都照搬。结果上线后详情页接口响应时间从 300ms 飙到 1.2s,库存扣减还出现了超卖迹象。 为什么现在要讲这件事?因为这两年数据库迁移和选型很热,但真正卡住大家的往往不是 CRUD、不是索引,而是从“表设计”到“文档设计”这道看不见的坎。 ## 01、痛点 很多人在学 MongoDB 时,把精力都放在 CRUD 和语法上,觉得会 `insertOne`、`updateOne`、聚合管道就算入门了。可真到迁移项目时,第一反应还是沿用关系库那套设计:每张表对应一个 Collection,字段一一照搬,外键照旧。结果就是前面说的场景:商品详情页要先查商品集合,再查 SKU 集合,再查属性集合,再查图片集合,最后再查分类集合。原本在 MySQL 里一条 `JOIN` 能拿到的数据,现在变成了应用层多次查询或一个巨大的聚合管道。 问题不在 MongoDB,而在我们用关系模型的思路去驱动一个文档模型。CRUD 只是招式,文档设计才是内功。官方文档里反复强调“按查询模式建模”,但很多人没当回事。等查询越来越慢、代码越来越复杂、事务越来越多,才意识到真正卡住自己的不是语法,而是设计。 ## 02、是什么 MongoDB 的文档模型和关系模型有本质区别。关系库里,数据被拆成扁平的二维表,用主外键维护关系,追求消除冗余,这就是第三范式。而 MongoDB 的一条文档可以嵌套数组和子对象,天然可以表达“一对多”甚至“多对多”的结构。 设计 MongoDB 模型时,核心问题只有一个:数据是嵌入(Embed)还是引用(Reference)。官方给出的建议是:先分析应用 Workload,也就是工作负载,再决定。具体来说,如果几类数据总是一起被读取,而且读取频率远高于更新频率,数据量有界,通常倾向于嵌入;如果数据需要独立查询、更新频繁、可能无界增长,或者需要单独管理,就更适合引用。 放到商品中心这个例子里,商品和 SKU、属性、图片几乎总是同时出现在详情页和列表页,这是典型的“一起读取”。SKU 数量通常几百个以内,属性十几条,图片几张到十几张,都是有界且读多写少,因此天然适合嵌进商品文档。而分类则不同,分类往往独立维护,一个分类下有大量商品,分类名称可能单独展示,也可能作为筛选条件被独立查询,所以更适合引用。 ## 03、为什么 为什么把 MySQL 每张表机械转换成一个 Collection 通常得不到 MongoDB 的优势?因为这样做等于保留了关系模型的结构,却丢失了关系数据库最擅长的事情——优化器、索引和成熟的 JOIN 机制。MongoDB 虽然提供了 `$lookup` 来模拟关联查询,但它本质上是在运行时做额外聚合,开销远高于关系库的预编译 JOIN。把五张表拆成五个集合后,每次详情页请求都要做多次磁盘读取或者多次 `$lookup`,随机 IO 增多,延迟自然就上来了。 更关键的是,文档数据库的核心优势之一就是单文档原子性和局部性:一个文档内的数据读写是原子的、一起加载的。如果模型设计合理,一次详情页查询只需要读取一个文档;如果像关系表那样拆开,就放弃了这一优势。另一个优势是横向扩展时的数据分片:如果商品和 SKU 分散在不同分片,跨片查询代价很高;但如果 SKU 嵌入商品文档,它们天然就在同一个分片上。 机械平移还会造成“四不像”:既没有关系库的强一致性约束,又没得到文档库的读取性能,代码里还要自己维护外键一致性。所以,不是 MongoDB 不行,而是需要真正按照文档思维重新设计。 ## 04、架构 传统的商品中心通常有五张关系表:`product`(商品主表)、`sku`(库存量单位,含颜色、规格、价格、库存)、`attribute`(商品属性键值对)、`image`(商品图片列表)、`category`(商品分类)。它们通过 `product_id`、`category_id` 等外键关联。在 MySQL 里,这是一套标准的第三范式设计,没什么问题。 迁移到 MongoDB 后,我们希望大多数商品查询一次就能拿到完整数据。于是把 `sku`、`attribute`、`image` 都嵌入到 `product` 文档中,而 `category` 保留为独立的 `categories` 集合,商品文档里只存一个 `category_id` 引用。新的 Product Document 大致长这样: ```json { _id: 'prod_10001', name: '氮化镓充电器 65W', category_id: 'cat_20', skus: [ { sku_id: 'sku_1', color: '白色', price: 129, stock: 200, attrs: { interface: '2C1A', power: '65W' } }, { sku_id: 'sku_2', color: '黑色', price: 129, stock: 50, attrs: { interface: '2C1A', power: '65W' } } ], attributes: [ { name: '品牌', value: '倍思' }, { name: '材质', value: 'PC' } ], images: [ 'https://cdn.example.com/prod_10001_1.jpg', 'https://cdn.example.com/prod_10001_2.jpg' ] } ``` 这个文档把详情页需要的主数据、SKU、属性、图片全都包含在一个文档里。一次 `db.products.findOne({_id: 'prod_10001'})` 就能拿到全部内容,不再需要跨集合拼装。 分类为什么保留引用?因为分类信息有独立生命周期:运营可能会单独修改分类名称、排序、上下级关系;分类也常作为列表页的筛选条件,独立查询需求明显;并且同一分类下可能有几十万商品,如果嵌入分类全量信息,冗余和一致性问题会很大。所以在商品文档里只放 `category_id`,需要分类名称时,再查一次 `categories` 集合,或者用缓存解决。 ## 05、实战 重构不是拍脑袋,必须从查询路径出发。我们第一步先列出商品中心最常执行的 Top10 查询,并标注频率和读写比。简化后的列表如下: | 编号 | 查询内容 | 频率 | 读写比 | | --- | --- | --- | --- | | Q1 | 按商品 ID 查详情页 | 极高 | 读多写少 | | Q2 | 按分类筛选商品列表 | 高 | 读多写少 | | Q3 | 扣减某个 SKU 库存 | 高 | 写多 | | Q4 | 修改某个 SKU 价格 | 中 | 写多 | | Q5 | 按属性组合搜索商品 | 中 | 读多 | | Q6 | 更新商品主图 | 低 | 写少 | | Q7 | 分类信息维护 | 低 | 读多写少 | | Q8 | 商品上架下架 | 中 | 写多 | | Q9 | 导出商品数据 | 低 | 读多 | | Q10 | 商品审核 | 低 | 读多写少 | 从这些查询可以看到,Q1、Q2、Q5、Q6、Q8 都涉及商品和 SKU、属性的整体读取或局部更新,所以把 SKU 和属性嵌入商品文档是合理的。Q3、Q4 虽然写多,但更新的是 SKU 数组内的某个字段,仍然在单文档内完成,可以用数组过滤更新。例如扣减库存可以写成: ```js db.products.updateOne( { _id: 'prod_10001', 'skus.sku_id': 'sku_1' }, { $inc: { 'skus.$.stock': -1 } } ) ``` 这条更新是单文档原子操作,不需要跨文档事务,也就避免了超卖。价格修改类似,用同样的数组定位方式更新 `skus.$.price`。 Q2 按分类筛选商品,由于商品文档里已经有 `category_id` 字段,只需要建立索引 `{ category_id: 1, created_at: -1 }` 即可。Q5 属性搜索虽然嵌入了属性数组,但可以建立多键索引 `{ 'attributes.name': 1, 'attributes.value': 1 }` 来优化。 分类信息独立成集合后,Q7 的分类维护完全不碰商品文档,运营改分类名称不会触发海量商品更新。详情页如果需要分类名称,可以在应用层做一次 `categories.findOne({_id: 'cat_20'})` 然后缓存,或者将分类名称冗余到商品文档中但采用异步修复。这里我们选择引用加缓存,保持商品文档的稳定。 重构完成后,我们对比了迁移前后的慢查询日志。商品详情页的平均查询次数从 6 次降到 1 次,响应时间从 1.2s 降到 180ms;库存扣减的 RT 从 80ms 降到 30ms,超卖问题消失。 ## 06、洞见 这次重构让我最深的感受是:MongoDB Schema 设计首先服务查询路径,而不是追求第三范式。关系库设计从实体和关系出发,目标是通过分解消除冗余;文档模型设计则从实际查询和更新出发,目标是最小化单次请求的读放大和跨文档操作。 在文档模型里,适当冗余不是罪过,反而是提高性能的常用手段。存储成本比计算成本便宜得多,把SKU、属性、图片塞进商品文档,虽然会让数据重复(比如多个 SKU 共享同一个属性组),但换来的是单文档读取的局部性和原子性。真正需要警惕的是写放大:如果某个嵌入数据变化非常频繁,且被大量文档冗余,那么每次更新都会引发大量写操作,这时就必须改成引用。 所以区分主数据和派生数据很关键。主数据应当尽量单点存储,比如分类名称;派生数据或者读多写少的数据可以冗余,比如商品上的分类名称。我们这里选择引用分类,是考虑到分类独立维护、商品数量大,冗余成本高。但如果分类信息极小且几乎不变,也可以嵌入分类名称,减少一次读取。 另一个洞见是:没有一劳永逸的模型。查询模式变了,文档设计也要跟着演进。官方强调 Workload-first,就是要求我们先量化查询频率和读写比例,而不是凭直觉。你可以把文档模型理解成针对 Top 查询的物化视图,如果 Top 查询变了,视图自然要重构。 ## 07、行动 如果你现在正打算把 MySQL 应用迁到 MongoDB,或者已经迁移但性能不佳,可以从下面几步开始纠偏: 1. 列出业务最核心的 Top10 查询,每条查询标注频率、读写比、涉及字段。 2. 按查询路径把实体分成若干“读取簇”,把总是同时出现的字段归为同一个簇。 3. 对每个簇判断是否适合嵌入:读多写少、数据量有界、需要原子性,就嵌入;独立查询、写入频繁、无界增长,就引用。 4. 设计文档结构时,优先保证 Top3 查询能通过一次文档读取完成。 5. 用真实数据量做压力测试,重点关注磁盘 IO、响应时间和写放大。 6. 上线后持续监控慢查询,定期复盘 Top10 查询是否变化,调整文档形状。 可以做一个简单表格来辅助决策: | 实体关系 | 是否一起读取 | 更新频率 | 数据量 | 决策 | | --- | --- | --- | --- | --- | | 商品-SKU | 是 | 低 | 小 | 嵌入 | | 商品-属性 | 是 | 低 | 小 | 嵌入 | | 商品-图片 | 是 | 低 | 小 | 嵌入 | | 商品-分类 | 偶尔 | 低 | 大 | 引用 | ## 08、小结 MongoDB 学习和迁移真正的第一道坎,不是 CRUD 语法,而是从“表设计”切换到“文档设计”。机械地把每张 MySQL 表转成一个 Collection,既丢了关系库的 JOIN 性能,又没得到文档库的单文档原子性和局部性。正确姿势是:先分析应用 Workload,列出 Top10 查询,再反推 Document Shape。频繁一起读取的数据倾向嵌入,独立查询、频繁变化或无界增长的数据更适合引用。商品中心的实战证明,按查询路径重新设计后,详情页查询次数从六次降到一次,性能提升明显。 ## 09、链接与概念说明 文中涉及几个关键概念,这里集中说明: - **文档模型(Document Model)**:MongoDB 的基本数据模型,一条记录称为文档,可以包含嵌套数组和子文档。 - **嵌入与引用(Embed vs Reference)**:MongoDB 数据建模的核心决策。嵌入将关联数据放在同一个文档中,引用只存关联键,需要时再查询。 - **Workload Analysis(工作负载分析)**:在建模前分析应用的读写模式、查询频率、数据量、读写比例等,指导建模。 - **第三范式与反范式化**:第三范式是关系库设计规范,强调消除非主属性对主属性的传递依赖;反范式化则是有意引入冗余以提升读取性能。 - **读放大与写放大**:读放大指一次逻辑读取需要多次物理读取;写放大指一次逻辑更新需要多次物理写入。文档建模需要在两者之间权衡。 - **$lookup**:MongoDB 聚合管道中的关联查询操作符,可以在查询时把两个集合的数据合并,但代价高于关系库 JOIN。 更多官方资料可以查看 MongoDB 文档中的 Data Modeling 部分:[https://www.mongodb.com/docs/manual/data-modeling/](https://www.mongodb.com/docs/manual/data-modeling/)。

优秀的个人博客,低调大师

《Spring Mongo实战》第12招 深分页也会慢

## 01、痛点 上周三夜里,我们一个用户 Feed 接口突然告警,P99 从 40ms 涨到 3.2s。我是老李,负责这块后端。第一反应是查 MongoDB 慢日志,看到一条语句: ``` db.feeds.find({ userId: 'u_1001' }).sort({ createdAt: -1, _id: -1 }).skip(200000).limit(20) ``` 这条语句来自运营后台,用户点到了第 1000 页。我们原本以为换了 MongoDB 就不会再遇到 MySQL 那种 LIMIT 100000,20 的深分页,结果还是被打脸。更麻烦的是,这个接口不是低频后台,而是 C 端 Feed 的旧实现,一直沿用 page、size 和 total。数据量从 5 万涨到 200 万后,深页查询把连接池占满,CPU 飙到 90%。 深分页的成本在哪儿?MongoDB 的 skip 并不是直接跳到第 200001 条位置,而是按排序顺序把前面 200000 条索引项扫描并丢弃,然后再返回 20 条。第 1 页只检查 20 条,第 1000 页却要检查 200020 条。问题不在 SQL 还是 NoSQL,而在“跳过大量偏移量”这个访问模式。这就是今天要聊的话题:为什么 MongoDB 不会自动消灭深分页,以及怎么用 lastId + createdAt 游标翻页解决 C 端顺序消费场景。 ## 02、是什么 先分清两个概念:偏移分页和游标分页。偏移分页就是常见的 skip/limit 或 SQL 的 OFFSET/LIMIT。它明确知道总页数,可以跳页,但成本随 offset 增大而上升。游标分页也叫 Seek Pagination 或 keyset pagination,它不传页码,只传上一页最后一条记录的游标,然后查询比游标更旧的数据。 MongoDB 中游标分页依赖两个关键点:排序稳定性和索引顺序。排序稳定性是指,如果只按 createdAt 降序排序,当很多记录 createdAt 相同时,MongoDB 不保证这些记录的顺序每次查询一致,翻页就可能重复或漏掉。解决方法是加一个唯一字段做决胜条件。这里我们选 _id,因为 _id 天然唯一且不可变。排序写成: ``` { createdAt: -1, _id: -1 } ``` 游标就需要同时记录上一条的 createdAt 和 _id。下一页查询条件分两段:要么 createdAt 更早,要么 createdAt 相等但 _id 更小。用 $or 表达: ``` { $or: [ { createdAt: { $lt: new Date(lastCreatedAt) } }, { createdAt: new Date(lastCreatedAt), _id: { $lt: ObjectId(lastId) } } ] } ``` 这样查询可以走 { createdAt: -1, _id: -1 } 或 { userId: 1, createdAt: -1, _id: -1 } 的范围索引,从游标位置继续向后取一页,而不是从第 0 条开始跳过。 所以,Seek Pagination 的本质是把“按数量跳过”变成“按值定位”。索引擅长按值定位,不擅长按偏移计数。 ## 03、为什么 为什么 MongoDB 不会自动消灭深分页?因为 skip 的语义没有变。即使底层是 WiredTiger 的 B 树索引,也没有办法直接定位到“第 200001 个”位置。偏移量不是索引键,索引只能高效定位到某个值或值范围,不能高效跳到某个编号。skip 200000 时,引擎必须从排序起点开始,逐个索引项扫描并丢弃 20 万条,再把后面的 20 条返回。 如果排序字段和查询条件没有完全命中索引,情况更糟。MongoDB 还可能把大量文档读入内存排序,超过 100MB 直接报错。即使命中索引,深页的 totalKeysExamined 和 totalDocsExamined 依旧会膨胀,执行时间随页数线性增长。 在分片集群里,这种成本会被进一步放大。mongos 需要从各个分片拉取符合排序条件的数据,合并排序后再执行 skip。深页意味着每个分片都可能返回巨量数据,网络传输、内存排序、临时空间都会增加。换句话说,分布式不仅没有自动优化深分页,很多时候还会放大它。 SQL 的 OFFSET 也一样。很多团队把 MySQL 换成 MongoDB,代码只是把 LIMIT 100000,20 改成 skip(100000).limit(20)。数据库变了,访问模式没变,深分页自然不会消失。所以问题根源是访问模式:随机跳页天然昂贵,顺序消费天然可优化。 ## 04、架构 既然根因在访问模式,方案就要按访问模式拆开。我的建议是:后台管理保留 Pageable,用户 Feed 改 Cursor Pagination。 后台管理为什么保留 Pageable?因为运营、客服、风控确实需要知道总页数,也需要跳到某一页。后台使用频率低,数据规模通常可控,保留页码分页可以接受,但要限制每页最大条数,比如 20/50,并且最好限制最大深度或提供筛选、搜索、时间范围,避免人工点到第 1000 页。 用户 Feed 则完全不同。C 端用户几乎不会点击页码跳页,他们的行为是向下滚动,不断加载更多。这种顺序消费场景天然适合游标分页。接口不再出现 page、size、total,而是: ``` { 'items': [...], 'nextCursor': 'MTY...', 'hasMore': true } ``` nextCursor 由最后一条记录的 createdAt 与 _id 编码而来,base64 之外还可以加一层签名,避免客户端伪造。服务端解析后生成 $or 条件,继续走范围查询。 索引设计上,Feed 集合典型查询是某个用户的时间线,复合索引可以设计为: ``` { userId: 1, createdAt: -1, _id: -1 } ``` 等值字段 userId 放最前,排序字段 createdAt、_id 按查询排序顺序放后面。这样每个用户的时间线范围查询都能走索引,游标越深,性能和第一页仍然是一个量级。 架构上可以这样落地:网关层定义两套分页协议。后台管理传 page 和 size,Feed 传 cursor 和 limit。后台服务继续用 Pageable。Feed 服务只接受 CursorRequest,如果发现客户端传 skip 或 page,直接返回参数错误,从协议上杜绝深分页。 ## 05、实战 先造一批数据。假设有一个 feed 集合,用下面的 Mongo shell 脚本插入 20 万条: ``` for (let i = 0; i limit; const resultItems = hasMore ? items.slice(0, limit) : items; const nextCursor = hasMore ? encodeCursor(resultItems[resultItems.length - 1]) : null; return { items: resultItems, nextCursor, hasMore }; } ``` 这里多取一条 limit + 1,只是为了判断 hasMore,不会额外返回给客户端。encodeCursor 可以把最后一条的 createdAt 和 _id 编码成字符串;decodeCursor 则负责解析并校验 ObjectId 是否合法。 用游标翻页连续请求 100 页,延迟基本和第一页一致。因为每一页都从索引的某个值开始,范围扫描只检查 21 条左右。再 explain 一次,totalKeysExamined 大约是 21,而不是 200021。 有两个坑要提醒。第一,createdAt 可能重复,所以必须带 _id 做决胜条件,否则相同时间的数据可能在翻页时被漏掉或重复。第二,游标字段必须是不可变字段。如果业务会修改 createdAt,就不要用 createdAt 做游标,改用 _id 或一个不可变的时间字段。 ## 06、洞见 这次踩坑给我最大的启发是:分页问题来自访问模式,而不是来自 SQL 还是 NoSQL。很多团队迁移到 MongoDB,只是把 OFFSET 换成 skip,分页交互还是“第几页/共几页”。只要用户仍然能跳到第 1000 页,深分页成本就不会消失。 数据库能帮你做的是优化某种访问模式,而不是消除访问模式本身。MongoDB 的索引可以快速定位到某个值或范围,但无法高效定位到第 N 条。键集分页利用前者,偏移分页掉进后者。 这也能解释为什么后台可以继续用 Pageable。不是后台不会遇到深分页,而是后台访问频率低、用户量少,限制深度后成本可以接受。真正高频的 C 端 Feed,必须改造成顺序消费。 如果产品坚持要总页数,我建议先让产品回答一个问题:用户真的需要跳到第 1000 页吗?大多数情况下,他们要的是搜索、筛选、时间范围。优化分页,有时不是改 SQL 或 MongoDB,而是改产品交互。 所以最后我们定了两条原则:能顺序消费就不要随机跳页;需要跳页就限制深度。后台管理保留 Pageable,用户 Feed 改 Cursor Pagination。 ## 07、行动 落地时,我建议按下面步骤推进。 第一,盘点所有 MongoDB 分页接口,找出 skip 超过一定阈值的查询。慢日志里通常能看到 totalKeysExamined 很大的语句。 第二,按场景分类。后台管理保留 Pageable,但设置每页最大条数不超过 50,并限制允许翻到的最大深度,或引导使用搜索和筛选。C 端 Feed 改造成 Cursor Pagination,不再暴露 page、size、total。 第三,建索引。确保索引顺序和查询排序一致。典型像 { userId: 1, createdAt: -1, _id: -1 }。用 explain('executionStats') 验证 totalKeysExamined 是否接近每页条数。 第四,接口改造。Feed 接口返回 nextCursor 和 hasMore,前端改为滚动加载。旧接口可以保留一段时间做灰度,新版本先发前端兼容,稳定后再下线 skip 实现。 第五,测试。用相同数据量对比 skip 深页和游标翻页的延迟与扫描数。形成回归用例,保证以后不会有人在 Feed 接口里加回 page 参数。 第六,监控。观察慢查询、游标接口的 P99、索引命中率。还可以设置告警,当某条查询 totalKeysExamined 超过阈值时通知。 决策表如下: | 场景 | 分页方式 | 是否返回 total | 是否可跳页 | 深度限制 | | 后台管理 | Pageable | 是 | 是 | 限制每页条数和最大深度 | | 用户 Feed | Cursor | 否 | 否 | 顺序加载 | | 内部导出 | 流式游标 | 否 | 否 | 顺序扫描 | ## 08、小结 MongoDB 的 skip/limit 用起来简单,但深分页成本会随偏移量线性增长。换 NoSQL 不会自动消灭深分页,因为真正的根因是访问模式:随机跳页与顺序消费的成本结构不同。 解决思路不是一刀切,而是按场景拆开。后台管理保留 Pageable,限制深度;用户 Feed 改用 lastId + createdAt 游标翻页,配合 { userId: 1, createdAt: -1, _id: -1 } 这样的索引,让每页查询只扫描固定条数,不再越翻越慢。 数据库不会替你决定访问模式。先把交互和接口协议管住,才是解决深分页的第一步。 ## 09、链接与概念说明 概念说明: - 深分页:跳过大量偏移量后才返回结果的分页方式,成本与偏移量正相关。 - 游标翻页:用上一页最后记录的排序字段值作为起点继续查询,也叫 Seek Pagination 或 keyset pagination。 - 排序稳定性:排序字段相等时,用唯一字段 _id 做决胜条件,避免翻页重复或遗漏。 - 索引顺序:查询的排序字段和顺序需要与复合索引匹配,否则可能出现内存排序。 - ESR 原则:复合索引优先放置等值字段,再放排序字段,最后放范围字段。 建议阅读 MongoDB 官方文档: - Cursor.skip:docs.mongodb.com/manual/reference/method/cursor.skip/ - Cursor.sort:docs.mongodb.com/manual/reference/method/cursor.sort/ - Explain Results:docs.mongodb.com/manual/reference/explain-results/ - ObjectId:docs.mongodb.com/manual/reference/method/ObjectId/ - Compound Indexes:docs.mongodb.com/manual/core/index-compound/

优秀的个人博客,低调大师

《Spring AI 2.0实战》第16招 五种Agent模式

最近团队在做技术选型,需要一份关于开源 API 网关的调研报告。我是老李,起初把任务丢给一个模型,让它一次生成。报告很快出来了,但问题也多:官网链接已经 404,GitHub 的 star 数与实际页面不一致,论文部分只摘了摘要没有结论。最要命的是,模型自己并不觉得有问题,也不会主动回去修改。我们需要的不是更长的输出,而是把任务拆成可检查、可回退、可组合的步骤。今天就用“生成技术调研报告”这个真实场景,聊聊五种协作模式到底怎么落地。 ## 01、痛点 单次生成技术调研报告,通常会遇到四类问题。 第一,信息源单一。模型倾向于从训练数据中拼凑答案,而不是实时访问官网、代码仓库和论文数据库,导致版本、许可证、维护状态等关键信息失真。比如有一份报告里写着“最近版本为 3.3”,实际上项目仓库已经发布了 4.0。 第二,任务边界不清。一份调研报告同时包含背景梳理、社区活跃度评估、性能对比、风险判断,混在一个生成过程里,哪个环节出错很难定位。我们曾遇到报告结构完整,但性能对比部分完全缺失,因为模型把注意力放在了功能列表上。 第三,没有校验闭环。输出完就结束,缺乏“检查—发现问题—重新生成”的机制。模型不会自己质疑自己,尤其当错误藏在细节里时,读的人往往最后才发现。 第四,节点越多越容易失控。很多团队一开始就画一个复杂流程图,结果状态传递混乱,调用成本高,效果反而不如直接生成。这不是说不要拆分,而是说拆分必须有明确边界。 ## 02、是什么 这五种协作模式分别解决不同问题。 链式(Chain)把任务切成前后依赖的步骤,前一步输出作为下一步输入,例如先搜索再摘要最后格式化引用。适合流程天然线性、后一步强依赖前一步的场景。 并行(Parallelization)用于相互独立的子任务,例如同时查官网、GitHub 和论文,最后由合并步骤汇总。适合数据源不同、彼此没有依赖的场景。 路由(Routing)根据输入特征把任务交给专用处理器,例如先判断主题是软件选型还是学术综述,再走不同分支。适合入口多样、不同输入需要不同模板的场景。 编排—执行(Orchestrator-Workers)由一个编排者动态拆解任务,分派给多个执行者,再回收结果并整合。适合任务边界不预先固定、需要按情况拆解的场景。 评估—优化(Evaluator-Optimizer)先由生成者产出初稿,再由评估者指出缺陷,生成者根据反馈修改,形成循环。适合质量要求高、需要多轮校正的场景。 它们不是五个孤立工具,而是一组可以组合的过程控制手段。 ## 03、为什么 现在模型能力虽然强,但复杂任务仍然不能靠一次调用解决。技术调研报告对事实准确性要求高,涉及多个异构数据源,单一模型容易产生幻觉和信息滞后。五种模式的可贵之处在于,它们不改变模型本身,而是改变任务的组织方式。把过程拆开后,我们可以在任意节点插入人工确认、日志、缓存和重试,成本可控。对于技术团队,这意味着从“祈祷模型一次答对”转向“用流程保证结果可验证”。特别是在数据会变化的场景里,单次生成得到的是一份静态快照,而协作流水线得到的是可以持续更新的报告。 ## 04、架构 整体流程可以抽象为六个阶段:入口先经过路由识别主题,然后交给编排者拆解,编排者把子任务分派给官网、代码仓库、论文三个执行者,三者并行工作;结果合并后交给评估者检查,通过则输出报告,不通过则发回对应执行者或重新拆解。 路由节点接收“开源网关选型调研”,判断主题为“开源软件评估”,选择评估模板。编排者拆解三个子任务:官网调研、代码仓库调研、论文调研。三个执行者并行执行,各自返回结构化片段。编排者合并片段,处理冲突。评估者检查数据一致性、信息时效、维度完整性,给每个子任务打分。低于阈值就送回对应节点重做,直到通过。 状态上需要维护任务清单、上下文快照、版本号和重试计数。每个节点只负责自己那一段,输入输出保持结构化,避免把上一节点的自然语言直接透传给下一节点。比如官网执行者只接收项目名称和官网地址,返回许可证、最新版本、维护者三个字段;代码仓库执行者只接收仓库地址,返回 star 数、open issues 数、最近提交时间。 ## 05、实战 以“生成技术调研报告”为例,任务输入是:“调研开源 API 网关项目,产出选型建议”。 第一步,路由判断主题。路由节点根据关键词和项目上下文,输出 task_type = software_evaluation,并选择对应模板。如果没有路由,这份任务可能会被套用学术论文模板,漏掉社区活跃度。 第二步,编排者拆解。拆成三个子任务: - 官网调研:许可证、维护者、架构特性、最近发布版本。 - 代码仓库调研:star 趋势、PR 合并周期、issue 活跃度、最近提交。 - 论文调研:性能对比、安全分析、相关学术结论。 每个子任务都有明确的数据来源和返回字段,避免重复和遗漏。 第三步,并行执行。三个执行者各自调用外部检索工具,返回结构化 JSON 片段。例如官网执行者返回 license、latest_version、maintainer;代码仓库执行者返回 stars、open_issues、last_commit;论文执行者返回 performance_comparison、security_notes。 第四步,编排者汇总。把三份片段合并成初稿,若发现矛盾(例如官网称最近版本为 3.2,代码仓库 tag 最新为 3.1),先保留双方来源并标记冲突,而不是擅自丢弃一方。 第五步,评估者检查。检查维度包括:数据源是否更新到最近 30 天?GitHub 数据是否与页面一致?是否遗漏许可证、安全漏洞、替代方案?是否给出了可复现的性能对比?若未通过,生成一条反馈,比如“补充 Apache 2.0 许可证说明,重新抓取 GitHub 最新 release”,然后发回给编排者或对应执行者。 在一次实际运行中,官网调研节点返回了过期链接,评估者发现后没有直接通过,而是发回“官网链接 404,请从项目 README 或文档站重新获取许可证信息”。执行者重做后,报告才被放行。这个回退过程只重做官网一个节点,不会牵连代码仓库和论文部分。 ## 06、洞见 回到那个问题:是不是节点越多效果越好? 答案是否定的。五种模式的真正成本来自模型调用次数、状态复杂度和不可预测性。每增加一个执行节点,就多一次输入输出、多一份状态要维护、多一个可能出错的位置。以调研报告为例,官网、代码仓库、论文三个节点是合理的,因为数据源不同、无法互相替代;但如果再把代码仓库拆成 star 调研和 issue 调研两个节点,往往收益很低,因为两者可以从同一个页面一起获取。 可以用一句话衡量:节点价值 = 减少的返工成本 - 新增的调用与状态成本。如果一个节点不能替下游省下返工,或者不能补上不可替代的事实来源,就应该合并。好的编排不是图越复杂越牛,而是每个节点都像流水线上的质检工位,有明确的拦截清单。 ## 07、行动 建议先小后大,不要一上来就画大一统流程图。 第一步,把链式、并行、路由、编排—执行、评估—优化各写一个最小 Demo。链式最小 Demo 可以串三个步骤:搜索、摘要、引用格式化。并行最小 Demo 可以同时调用三个独立接口,最后合并。路由最小 Demo 用规则或分类模型把输入分到两个模板。编排最小 Demo 用一个编排者拆出两个执行者。评估最小 Demo 对初稿打分并修改一轮。 第二步,分别跑通五个最小闭环,确认各自能独立运行。 第三步,再组合到技术调研报告场景。组合时先固定数据格式和状态结构,避免节点间字段混乱。 第四步,给每个节点记录调用次数、耗时、失败率,数据不达标就合并或删除节点。验收标准可以简单:报告中的每个关键事实都能追溯到具体来源;任何节点失败都能单独重试;每次重做不需要重跑整个流程。 ## 08、小结 技术调研报告不是一个简单问答,而是一条需要多源采集、交叉校验、回退修正的流水线。链式解决顺序依赖,并行解决独立子任务,路由解决入口分流,编排—执行解决动态拆解,评估—优化解决质量闭环。五种模式组合起来,不是为了把图画得复杂,而是为了让每一步都可控、可回放、可修正。节点不是越多越好,每个节点都必须有明确价值。 ## 09、链接与概念说明 本文涉及的五个概念: - 链式:串行处理,前一步输出作为后一步输入。 - 并行:独立任务同时执行,最后合并。 - 路由:根据输入特征选择专用处理分支。 - 编排—执行:编排者动态拆解任务并分派执行者,回收结果。 - 评估—优化:生成与评估循环,直到满足质量条件。 进一步阅读可参考 Anthropic 官网关于多步骤协作模式的公开技术文档,以及模型 API 文档中的函数调用和结构化输出部分。也可以搜索“多阶段推理”和“结构化生成”等资料,但本文的核心价值在于:先拆成最小闭环,再按价值决定是否增加节点。

优秀的个人博客,低调大师

《Spring AI 2.0实战》第17招 Reflection怎么做

## 01、痛点 我是老李。最近在给一个核心业务做架构方案评审时,遇到一件让人哭笑不得的事。团队一位同事用大模型直接生成了一份架构设计文档,拿过来一看,章节齐全、术语专业、图表也画得像模像样。但细读下去,问题就来了:没有限流策略,没有备份方案,多可用区只写了一句“建议部署在多个区域”,成本预估则拍脑袋写了一个数字。最要命的是,模型自己在文档末尾还加了一句“本方案已经过全面评估,具备生产级可靠性”。 这件事让我开始认真思考一个被越来越多团队讨论的做法:让模型对已有的结果进行批评和修正。官方示例里也专门提供了类似的反射实现,用模型对输出做二次评审。但问题来了:模型自己评价自己,到底靠不靠谱?为什么现在要讲这个?因为很多团队已经把它当成了默认动作,每个回答都多跑一次模型,成本翻倍,效果却未必更好。今天就把原理、实战和边界一次说清楚。 ## 02、是什么 先给这个做法下一个定义。所谓反射机制,英文叫 Reflection,本质上是一个“生成 → 评价 → 修改”的反馈回路。具体来说,模型先根据需求产出一份结果,然后由另一个评估角色对这份结果从指定维度进行批评和打分,如果分数不达标,就把批评意见反馈给生成环节,重新生成,直到满足条件或者达到最大轮次。 这个思路和经典的“评估-优化”模式非常接近。在软件工程里,我们早就熟悉这种结构:开发人员写代码,测试人员挑毛病,开发人员再修改。只不过在反射机制里,生成者和评估者往往由同一个底层大模型担任,只是通过不同的角色设定和输入要求来区分职责。生成者负责创造,评估者负责找茬,生成者再根据反馈修改。 需要注意的是,反射不是简单地让模型“多看一遍自己的答案”。它必须引入明确的外部评价维度,例如安全、成本、可靠性、可维护性,并且要求评估器给出具体批评和量化评分。如果只让模型“你再检查一下”,通常效果很有限,因为缺乏明确的评价标准,模型很容易说“看起来没问题”。 ## 03、为什么 为什么需要反射?核心原因是大模型单次生成存在明显的局限性。对于简单问答,一次生成通常足够;但对于架构方案、复杂代码、合同条款、安全策略这类高价值复杂输出,单次生成就像第一版草稿,表面合理但细节往往经不起推敲。模型容易遗漏边界条件、忽略真实的成本约束、或者把常见模板套用在不合适的场景上。 反射的价值在于把隐性的质量要求显性化。我们心里可能知道一份架构方案“应该具备高可用”,但模型不知道。通过评估器把这个要求拆成具体维度,例如“是否包含限流和熔断”“是否有多可用区部署”“是否有数据备份策略”,再逐项打分,模型就有机会发现自己遗漏了什么,并在下一轮补齐。 另外,反射的成本相对人工评审要低得多。一个资深架构师审一份方案可能需要半天,而模型评估只需要几秒钟。对于高价值输出,这种成本是值得的。但不能因此把反射当成万能药,这正是后面要展开讨论的关键。 ## 04、架构 一个典型的反射机制可以抽象成四个部分。 第一是生成器。它接收原始需求和上一轮的反馈意见,生成一份新的结果。生成器可以是同一个模型,只是输入中包含了反馈。 第二是评估器。它接收生成器产出的结果,配合预先定义好的评价维度和打分标准,输出一个评分和具体的批评意见。评估器的输入设计非常关键,如果评价标准模糊,例如只写“评估方案质量”,评估器就很难给出有用的反馈。实际使用中,通常会把每个维度拆成若干子项,并要求逐项打分。 第三是循环控制。它负责判断评分是否达到阈值、是否超过最大轮次。如果达标,就结束并输出结果;如果未达标,就把批评意见作为反馈传给生成器,进入下一轮;如果轮次耗尽仍未达标,则转入人工评审或直接丢弃。 第四是最终输出。可以是达标结果,也可以是附带评估记录和未解决问题的半成品,供人工介入。 用一段伪代码表示这个流程: ```python for i in range(max_rounds): draft = generate(requirement, feedback) score, comments = evaluate( draft, dimensions=['security', 'cost', 'reliability'] ) if score >= threshold: return draft feedback = comments raise NeedHumanReview(draft, score, comments) ``` 这段逻辑并不复杂,但实际效果取决于评估器的维度和标准是否合理。很多团队在这一步偷懒,只写一句“请评估这份方案”,结果反射流于形式。 ## 05、实战 拿一个真实场景来演示。假设我们要设计一个电商订单系统的架构方案,要求是高可用、安全、成本可控。先让模型生成第一版方案。 第一版方案大概是这样的:前端通过负载均衡访问后端服务,后端使用无状态服务集群,数据库采用 MySQL 主从复制,缓存使用 Redis,文件存储使用对象存储。听起来很标准,但很多关键细节缺失。 我们把这份方案交给评估器,从三个维度打分:安全、成本、可靠性。每个维度 0 到 5 分,总分低于 12 分就判定不通过。评估器给出的批评可能包括: - 安全:未提及 API 鉴权、传输加密、敏感数据落盘加密、SQL 注入防护,得 2 分。 - 成本:未考虑数据库连接数、缓存规格、跨可用区流量费用,也没有冷热数据分层,得 2 分。 - 可靠性:缺少限流、熔断、降级、备份策略、多可用区部署说明,得 2 分。 总分 6 分,不通过。反馈写回生成器。第二轮,生成器根据批评补上:增加 API 网关层的鉴权和限流,启用 TLS 传输加密,对用户敏感字段做应用层加密,增加 WAF,数据库改为多可用区部署,Redis 使用哨兵模式,增加每日全量备份和实时 binlog 备份。再次评估,安全 4 分,成本 3 分,可靠性 4 分,总分 11 分,仍差一点。反馈指出成本估算仍然没有具体依据。第三轮,生成器补充了按量付费、预留实例、冷热数据分层和缓存容量评估。最终安全 4 分,成本 4 分,可靠性 4 分,总分 12 分,通过。 这个例子说明反射确实能补上常见遗漏。但注意,评估器对“成本 4 分”的判断本身可能并不可靠——它并不真正知道云资源单价,也不清楚业务实际流量。这正是反射的边界。 ## 06、洞见 这里必须回答一个尖锐的问题:模型自己评价自己,真的可靠吗? 答案是:部分可靠,但远非完全可靠。反射能提升的是“维度覆盖”和“结构完整性”,它更擅长发现“该写没写”的问题,而不是“写了但写错”的问题。例如,补充了备份策略,但备份策略是否真的能恢复数据?评估器很可能判断不了。补充了多可用区,但跨可用区网络延迟对业务的影响有多大?评估器也无法精确评估。 根本原因在于,评估器和生成器共享同一个能力边界。如果底层模型对某个领域的知识存在盲区,那么生成器会犯错,评估器同样会忽略。就像一个实习生写的方案,另一个实习生来审,可能两人都不清楚某个行业合规要求。模型还可能对不确定的事情表现出过度自信,给出看似有理但事实错误的评分。比如成本估算,模型可能编出一个听上去合理的数字,但和云平台实际价格相差很大。 此外,反射还有一个隐性风险:如果评估标准设计不当,模型可能学会迎合标准,而不是真正提升质量。例如只考核“是否提到限流”,模型就会在所有方案里机械地加上“限流”二字,却没有具体阈值或实现方式。这会导致一种表面合规、实际空洞的输出。 因此,反射可以提升部分复杂任务质量,但不能替代确定性的验证、测试和人工审核。确定性的验证包括单元测试、静态扫描、成本计算器、故障注入、安全基线检查等,这些工具不依赖模型的主观判断。人工审核则负责把握那些需要经验和责任兜底的环节。 一句话总结:反射是内审,不是外审。内审有用,但不能取代外部审计。 ## 07、行动 既然反射有边界,我们应该怎么用? 第一,只把反射放到高价值复杂输出上。架构方案、安全策略、合同条款、复杂算法实现,这些值得多跑几轮。普通问答、摘要、文案润色,没必要增加第二次模型调用,因为成本和延迟增加,质量提升却很有限。 第二,评估维度要具体、可打分。不要写“评估方案质量”,而是写“从安全、成本、可靠性三个维度打分,每项 0 到 5 分,列出扣分原因”。维度越具体,反馈越可执行。 第三,设置最大轮次和达标阈值。一般 2 到 3 轮即可,超过这个次数往往收益递减。如果仍未达标,应该转入人工评审,而不是无限循环。 第四,在反射之后加一道确定性校验。例如用云成本计算器核算方案成本,用静态扫描工具检查安全配置,用故障注入测试验证高可用。反射负责查漏,确定性校验负责纠错。 第五,记录评估历史。每次评估的意见和修改过程最好留存,这样能分析模型反复犯的错,也能优化评估标准。 ## 08、小结 反射机制本质上是“生成 → 评价 → 修改”的反馈回路,与评估-优化模式一脉相承。它能帮我们把隐性的质量要求显性化,在架构方案、复杂代码等高价值场景中提升完整性和常见维度覆盖。但模型自己评价自己并不完全可靠,它受限于自身能力边界,不能替代确定性验证、测试和人工审核。 真正成熟的做法,是把它当成一道内置评审工序,而不是质量背书。懂得什么时候用、怎么用、什么时候停下,比盲目叠加模型调用重要得多。 ## 09、链接与概念说明 - Reflection:反射/反思,指让模型对已有输出进行批评和修正的机制,属于“生成→评价→修改”的反馈回路。 - Evaluator-Optimizer:评估-优化模式,将生成和评价分离,由评估器驱动生成器反复优化,与反射机制思路相近。 - 确定性验证:不依赖模型主观判断的检查手段,例如单元测试、静态扫描、成本计算器、故障注入、合规基线检查等。 - 官方示例:主流大模型平台的示例中提供了反射机制的参考实现,可查阅相关文档了解具体用法。 - 推荐实践:将反射作为复杂输出流程中的中间环节,而不是最终质量关卡,最终输出前应尽量经过确定性校验或人工审核。

资源下载

更多资源
Spring

Spring

Spring框架(Spring Framework)是由Rod Johnson于2002年提出的开源Java企业级应用框架,旨在通过使用JavaBean替代传统EJB实现方式降低企业级编程开发的复杂性。该框架基于简单性、可测试性和松耦合性设计理念,提供核心容器、应用上下文、数据访问集成等模块,支持整合Hibernate、Struts等第三方框架,其适用范围不仅限于服务器端开发,绝大多数Java应用均可从中受益。

Rocky Linux

Rocky Linux

Rocky Linux(中文名:洛基)是由Gregory Kurtzer于2020年12月发起的企业级Linux发行版,作为CentOS稳定版停止维护后与RHEL(Red Hat Enterprise Linux)完全兼容的开源替代方案,由社区拥有并管理,支持x86_64、aarch64等架构。其通过重新编译RHEL源代码提供长期稳定性,采用模块化包装和SELinux安全架构,默认包含GNOME桌面环境及XFS文件系统,支持十年生命周期更新。

优质分享App

优质分享App

近一个月的开发和优化,本站点的第一个app全新上线。该app采用极致压缩,本体才4.36MB。系统里面做了大量数据访问、缓存优化。方便用户在手机上查看文章。后续会推出HarmonyOS的适配版本。

Sublime Text

Sublime Text

Sublime Text具有漂亮的用户界面和强大的功能,例如代码缩略图,Python的插件,代码段等。还可自定义键绑定,菜单和工具栏。Sublime Text 的主要功能包括:拼写检查,书签,完整的 Python API , Goto 功能,即时项目切换,多选择,多窗口等等。Sublime Text 是一个跨平台的编辑器,同时支持Windows、Linux、Mac OS X等操作系统。

用户登录
用户注册