3.333. 案例-有构架地解决问题

3.333.1. 介绍

这是我最近用AI写程序收集的一组案例,用来后续以后慢慢审视AI写程序会遇到的一些坑。这里涉及的AI主要是pi@glm-5.2,copilot@pro+auto和cursor@pro+auto。

我主要通过记录我给AI提的意见,删掉多余的部分,用来说明问题本身是什么。这些意见基本AI都是立即同意并且确实理解可以执行的,所以不存在“不同人有不同看法的问题”。

在后面的表述中,除非特别说明,这些我给它的表达用这样的格式表示:

我的意见。

如果要描述AI的响应,我直接在正文中表达,不使用额外的格式。

这里记录的都是比较复杂的案例,简单的“一句话案例”在这里:s_ai_mistake_examples

3.333.2. 用旧方式去解决新问题

我给AI提了一个意见:

你说你用alloc hint来区分这是什么通道的消息,我觉得这不妥。因为hint只是一个
提示,提示应该可以自由修改,用于信息显示的,不应该强绑定到某种协议上。我觉得
tcp也应该有一样的问题啊,slave或者client通过socket连上来,它应该也区分不了
是什么连接啊。它怎么解决这个问题的?

这是它的回答:TCP 的做法:连接时完全不知道对方身份。Slave 和普通 Client 连的是同一个 TCP 端口。直到收到 PSYNC/SYNC 命令,syncCommand() 才设CLIENT_SLAVE。身份由命令决定,不由连接决定。

你看,我们的方案本来每个消息都可以直接指定当前的身份的,但它模仿的那个模块天生受限,必须通过连接的前置消息才能决定报文的属性。它就会绕一道,强行降低新协议的能力,让这个能力去符合受限的旧协议的模式。

说清楚一点:如果用我们新加的协议,每个请求是个RPC,参数是RPC(cmd, business_type),我一开始就知道请求是什么身份的人发上来的。而原来的tcp协议,RPC是个流,先要把流分段,知道前置的消息,根据这些消息给这个流一个身份(business_type),AI已经知道自己的RPC的特性了,但它看着tcp来学,还是把这个问题重新解决了一遍了。

3.333.3. 构架分析的问题

这个问题是让AI整理一下已经被它改得混乱的代码呈现出来,主要可以看到为什么AI抓主要矛盾的能力不如人。

下面是我的意见:

这个梳理还是不够清晰。对于这种针对如何组织代码的梳理,首先我们应该永远站在
某个通讯方来分析问题,不能一时认为自己是通讯的A方,一时认为自己是通讯的B方,
这样很容易导致代码逻辑混乱,你的代码永远是运行在其中一方上的。站在这个基础上,
redisGqmPollEvent()返回的事件,对于本方来说,就会分是看到了Caller的状态更新
需要处理(通常是对端响应RPC了),还是Callee的状态更新了(通常是收到了某个RPC
请求),然后才开始找对应的client类型,确定匹配client的算法,找到client以后,
再驱动client的状态机如何运动。这样梳理才会更清楚。

还有:

这个版本的分析还是不太对,我们针对2.1来说,你认为Client Join就是callee,
callee就是client(用cid找client),但这个判断显然不对啊,因为完全有可能是其
他cluster node要主动发消息给你,所以把你作为callee申请一个client,这也是
client join,怎么可以这样分类?
我觉得这个设计还是有问题,我一点点来说:1. S2.2中,你总结Caller只有两种消
息,但其实就这个场景进行穷举,我至少想到这些:对端响应完成,这是CALLER_RSP,
对端响应长消息可以再发,这就没有类型可以表示了。而且这种情况还要分输入参数
再发和输出参数再发两种情况。要有办法区分。然后就是传输层错误。在这个场景中你
不能说没有吧?最后就是CALLER_ALLOC_DONE,这个其实要分分配成功还是失败,这
也是需要分开处理的。2. S2.3的Callee分析中,有人JOIN了,tcp的处理确实必须等收
到消息以后判断连接类型,如果gqm复用一样的算法,就需要先记录一个全局状态,然
后等收消息以 后,在决定创建clusterLink还是Client,但我们为什么不知道用
business type这个参数呢?这样一开始我们就可以决定创建什么类型,未来我们也
一直可以用这种方法正确使用cid,找到对应的模块来处理不同的业务啊。这个不是更
简单吗?这样我们的callee消息一进来,就可以分出业务,就按那个业务独立处理状态
机,这样状态机就很干净了。

3.333.4. 状态机问题

我首先看了你现在修改出来的qm_uu_rpc_slot_session和qm_uu_rpc_caller_session,
感觉变得越来越大,感觉实在没有道理。从文档描述的状态机来说,无论caller还是
callee,某个时刻只能是正在发送或者正在接收,不会同时发生,所以都只要一个状态
机,包括:当前状态,下一个收发位置的指针,剩下多少空间。这就是状态机的全部。
剩下是一些辅助参数,对于callee就是默认的接收buffer,这个因为callee是被动,相
当于保留一个复位回到的状态。但对于caller,就没有了,因为每次状态机初始化,
call的时候就会给定参数。这样说起来,怎么都不应该要这么多状态来表达这个状态机
啊。

这个问题解释了为什么AI生成的代码会比一般人写的大那么多。它增加功能总是趋向于增加状态,专为这个功能加状态。这个功能就会快速跑起来。但如果你以为这个功能对之前的功能描述的逻辑会起作用,那也是想多了。

上面问题它修改后,我接着对这个结果有如下意见:

我还是不明白,为什么需要state和lms两个状态?我推演整个调用过程状态机的时候,
是考虑了整个调用的全程的,为什么现在需要增加一个新状态。

它回答:你的模型「一个状态机 + 指针 + 剩余量」是对的;lms 是误把 transport 层临时变量存进了 session,现已去掉。若你还觉得 APP 可以合并进 RECV(例如用 total> 0 && remain==0 表达),我们可以再讨论是否进一步精简 state 枚举。

我接着反对:

你这个逻辑说不通。首先,lms才是一个会话真正需要的东西,如果一个会话收到一
个消息,它只会知道cid或者handle_id,没有一个直接关联到这个会话的状态,它怎么
知道要发什么消息?所以,这个状态不可能是在调用函数的时候临时创建的。一定是这
个会话全局状态的一部分。而你说的RECV->APP->SEND反而在我文档中的状态机中是全
程覆盖的,那个状态机已经包含了Caller和Callee的全部状态切换过程,不需要额外的
状态去辅助。

然后:

如果是这样,为什么你不一开始就把ctx记录在这个xfer中,而需要在调用
copy_in/out的时候重新生成一次这个结构呢?(这个其实是我们重构前的原始设计)

多次折腾以后,我又给AI提了两个意见:

你现在的描述让我发现了实现逻辑上的一些严重错误了。首先,共享内存里面的状态,
不是caller/callee的调度状态,我们不能用共享内存的状态来做本地状态的判断。举
个例子,caller发出一个GET_MORE消息,为了发出这个消息,它会设置共享内存中的
status,说明发出了消息,但这和caller背身的状态在WAIT_MORE这个状态无关。任何
时候,我们都不应该用共享内存的状态去判断本地的状态。你前面的描述表明现在的代
码并不严格区分这一点,这很容易就导致判断错误了。因为对端可能修改共享内存,导
致你的依赖就不成立。而且,从这个角度来说,如果我们设置了block的状态,然后发
消息到队列中失败,我们不用把block状态修改回来的,因为这些状态就是为了通讯做
的,下次发消息前状态字是正确,保证本端可以polling知道对端响应了请求就行了。
第二,你提到qm_uu_rpc_caller _call_start()中发生了重试?这是一个异步调用,无
论如何都不应该重试啊。不过关于这一点,你的总结中似乎已经意识到了。但我还需要
重复:我们现在的可靠性模型是“所有通讯方都是理性的”,“不能丢消息”,除非调用者
能知道,否则,一定不能出现“超过多少次就放弃”的处理方案。最后,caller重发让
polling自己做当然更友好,但如果后面来了新的调用,想要合并,或者超时要取消,
就又要增加接口了。所以我还是取向于让业务层自己重试。
我觉得你问的问题指向一个状态机设计的问题。我们设计状态机,最怕的是有多个状态
标记,这样处理起来真正的状态变迁图就是所有这些状态个数的积了,而我们穷举的所
有状态变迁就不完整了。我们最初设计的时候ctx甚至都是可选的,这加剧了一开始代
码的分裂,但现在我们已经直接内置在caller和callee的每个会话中,这给了我们统一
状态机的基础。我们任何时候都只能用ctx.state来判断当前的状态,那能不能发送的
条件,其实就只有一条:ctx.state在不在IDLE上。在IDLE上就可以发,不在,就要等
恢复到IDLE状态才可以发。这才是这个状态机的作用,也是我们在文档中推理那个状态
机的目的。好了,基于这一点,我们现在要问的是,如果发送一个消息,没有发成功,
这时ctx.state应该在什么状态?其实这不是通讯的状态,因为你没有发出去,不改变
任何通讯状态啊。所以这是另一个正交的状态机,你要发送,但没有发送出去,你希
望调度器过一阵子提醒你,这和通讯没有关系,是你调度器自己要记住一个独立的
caller的“是否重试”的状态,你根据这个状态记住是否要重试,然后在重试的时候继续
驱动通讯状态机的运转就行了,你说对吗?

然后我们看看AI的总结::

对,而且这个模型比你描述的更"被强制"——失败时不改 ctx.state 不是一种可选的优雅,
而是那条原则的直接推论。我把推理完整复述一遍,确认我理解到位:

ctx.state 唯一的语义是"通讯进度"。 所以"能否发送"的判据只有一条:ctx.state ==
IDLE。IDLE 意味着这个会话当前没有进行中的通讯、可以发起新的;非 IDLE 意味着有
进行中的通讯,必须等它收敛回      IDLE。这是文档里那个状态机存在的全部目的。
一次失败的发送,按定义不改变通讯进度——消息没进队列,对端永远看不到,没有任何"
通讯"发生。所以它对 ctx.state 的改动必须是零。对首个 chunk 而言:发送前
ctx.state==IDLE(发送条件已满足),发送失败 → ctx.state 仍是 IDLE。当前代码的
毛病正是违反了这一点:call_start/try_start 在发送前就把 ctx.state 置成 MORE,
失败时要么留在 MORE(拿 MORE       去兼表"待重试"),要么 abort 回 IDLE——这就
是你说的多标记积问题,MORE 同时背了"传输进行中"和"待重试"两个正交含义,状态变
迁图立刻不完整。

失败发送唯一允许留下的痕迹是那个正交标志。 它属于调度器,不属于通讯。语义是"
这个会话有一次没投出去的发送,请在下轮提醒我",与 ctx.state 完全解耦。

这里有个推论,恰好证明这个标志"不得不存在":失败的首个 chunk 发送之后,
ctx.state==IDLE,和"这个会话本来就是空闲的"在通讯状态上无法区分。所以"有没有
待重试"这个信息 ctx.state                 装不下——它只能、也必须装在那个正交
标志里。这不是绕过,是原则本身要求的。

于是一次失败的发送对会话是几乎纯 no-op:ctx.state 不动(仍 IDLE),传输上下文
(buf/remain/business_type)在形态 B
下也一并拆掉(数据所有权归业务层,重试时重新提供),唯一残留就是那个重试标志。
通讯意义上这次尝试等于没发生过——因为它确实没发生过。

当业务层被 RESEND 唤醒、重新 call_start 时:ctx.state==IDLE(发送条件天然满
足),这次重试就是一次普通的发送,走正常的 IDLE
检查和发送路径。通讯状态机甚至不知道这是"重试"——对它而言只是又一次从 IDLE 出
发的发送。所以重试不需要任何特殊发送路径,标志的唯一职责是"提醒业务层再调一次
"。

这样两台状态机各自单一职责:ctx.state 只表通讯进度,重试标志只表"调度器要不要
提醒"。没有重载,没有笛卡尔积。

它其实完全明白,但做的时候它就是没有能力从泥潭中把自己拔出来。所以关于状态机,我决定写一个skill:状态机设计

我写完这个Skill后,让AI帮我校验一下这个Skill写得是否有问题,他给我补充了一些条款,你可以直接看我给它的答复:

你补充的状态交织检测中提到判断状态属性的谓词如果结合了其他其他状态,就需要考
虑状态合并(也就是你提到的“读路径”)。我觉得这个说法不对,因为这意味了这个被
读的额外变量并不影响状态变迁,只是改变处理范式,这完全可以认为是处理范式的一
个参数,类似状态从s1->s2,条件是s1,但响应用到一组参数f(a, b, c, d),这些a,
b, c,d并不是需要合并到状态机中成为一个状态,对吗?后面的smell test和最后的补
充我都有一样的问题。所以,我觉得问题还是这个写路径,如果某个状态变量不影响切
换,无论它是什么状态,被推理的那个状态切换都是一样的,那这个额外的状态变量就
没有和原来的状态机发生交织。

这个问题是用pi@glm-5发生的,是一个非常明确的技术错误。以我的经验,我不觉得其他模型能表现更好。这是一个典型的,在非常封闭的逻辑空间中,它照样会犯错的问题。

顺便说一句,我前面提到的SKILL对纠正它的建模起相当大的作用,其他人如果有这样的问题,可以参考一下。

3.333.5. 测试问题

现在基本明白你的问题是什么了。首先,你的测试用例中存在同步调用,而同步调用
没有另一个线程去解除状态,就无法自动结束,只能等超时再……按你说的……teardown。
所以我觉得我们可以这样调整一下我们的测试,首先把用例分成同步和异步两类,前者
测试同步接口,这种接口用多线程来测试,测试调用两方的同步关系。后者只测试异步
接口,每次一个步骤,主要就是这些异步步骤的配合是否符合期待。

这个例子中,AI测试一个同步模型,却按开发库最初的单线程方式进行测试,它还很专业地使用了teardown机制,靠超时释放同步锁死的资源。但它自己就是没法从单线程跳到多线程这个逻辑上面跳出来。

顺便说一句,对于测试,一个基本的道理:不要针对覆盖率去设计用例,用接口功能去设计用例,测试完成以后再检查覆盖率,考虑什么功能没有测试到,在基于功能去设计新的用例。这个每个测试人员都应该掌握的原则,告诉AI和不告诉AI,测试效果是完全不同的。