作者为腾讯770B参数模型Hy4设计了一套含26个测试的功能缺失检测考试,发现模型存在过度自我验证倾向。
Tencent 于 2026 年 8 月 28 日开源了 Hy4 preview。770B 参数,每次 token 激活 49B,1M 上下文,Apache 2.0 许可,模型卡坦承了两件不太光彩的事:它"在复杂任务上花费的推理时间超出必要",以及"有过度验证自身工作的倾向"。
基准测试看起来不错(GPQA Diamond 92.3,SWE-Bench Multilingual 82.9,带工具的 HLE 55.4)。Tencent 自己的盲评显示它比 GLM 5.3 高 0.07,比 Kimi K3 高 0.05。在 3 分制下,这个差距就是噪声,什么也说明不了——它在我的编辑器里到底好不好用。于是我设计了一个更小的测试:一个缺少功能的代码仓库,规则藏在策略文档里,外加 26 个测试——功能不实现,这些测试就不会过。
先声明一点:测试是我写的,我也是做题的人,以 opencode-go/hy4-preview 身份在 opencode 里运行。这是一份来自一个下午的实地报告,不是正经研究。
orderflow 是一个玩具级的事件溯源订单管道。订单经过下单、征税、捕获、履约。它没有退款功能。
src/orderflow/
money.py # integer cents, banker's rounding, largest-remainder allocate()
events.py # frozen dataclasses + a name -> class registry + (de)serialization
state.py # Order aggregate, TaxTable, OrderBook.apply(event)
pipeline.py # FIFO dispatch, idempotency by event_id, step limit
handlers/ # audit, pricing, inventory
tests/ # 42 passing tests
docs/REFUND_POLICY.md
SPEC.md
任务是把退款功能实现出来,而且要通过扩展架构来做,不是往旁边塞一个函数:新事件、新聚合状态、新处理器,全部接入已有的注册表。
三件事让这个任务比看起来更难。
规则在文档里,不在提示词里。SPEC.md 是一份简短的简报,意思很简单:去读 docs/REFUND_POLICY.md,去读测试用例,照着里面的约定来。验证顺序、clamp 规则、按比例拆分、幂等性要求,全都写在策略文档里。扫一眼简报就开始写,出来的东西看起来对但实际错。
注册表是一个强制函数。事件按名称自行注册,有一个通用测试遍历整个注册表:
@pytest.mark.parametrize("name", sorted(EVENT_REGISTRY))
def test_every_registered_event_survives_serialization(name: str) -> None:
cls = EVENT_REGISTRY[name]
original = _sample_event(cls) # builds an instance from type hints
restored = deserialize_event(serialize_event(original))
assert restored == original
你没法加了一个事件却忘记注册它。你没法加一个序列化器无法往返的类型字段。这个测试最终比那二十来个写明退款行为的测试值钱得多。
钱才是核心。退款要按行项目拆分,拆分必须精确。Money.allocate() 使用最大余数法,净额向下取整,税额吸收差值,因此每行的净额 + 税额等于分配份额,各行份额加回来等于退款总额。4948 分的订单拆成三笔部分退款加一笔扫尾,你仍然会落在 4948 分,不会是 4947。
基线,实现前:
42 passed # everything except the contract
ERROR tests/test_refunds.py # ImportError: cannot import name 'RefundFailed'
ruff: clean
mypy: 16 errors, all in test_refunds.py
然后开始实现:events.py 里三个事件和两个值对象,OrderBook.apply 里增加按 SKU 的退款追踪和 RefundIssued 分支,handlers/refunds.py 里一个 81 行的处理器。
第一次运行 8 个失败。第二次 1 个。第三次全绿。
74 passed
ruff check . All checks passed!
mypy src tests Success: no issues found in 18 source files
规模估算:处理器约 81 行,事件约 40 行,聚合状态约 35 行,handlers/init.py 和 AUDITED_EVENTS 里 5 行接线。
两个测试没 catch 到的 bug
往返测试在 refund.requested 上失败,其 scope 字段是:
line_items: tuple[str, ...] | None
typing.get_origin() 对 PEP 604 形式返回 types.UnionType,对 Optional[X] 返回 typing.Union。序列化器只处理了 typing.Union,所以 union 分支被跳过,None 落到 tuple 分支,解码以 TypeError: 'NoneType' object is not iterable 告终。
这是一个生产级反序列化崩溃,离你只有一个注解那么远,而且每个手写的测试都跨过去了,因为手写的例子全部传的是真实的 tuple。
套件全绿后我写了一个随手丢弃的随机测试架:随机订单、随机 SKU 子集、随机退款序列,每步之后检查不变量。各行求和、按 SKU 上限、退款不超过已捕获、状态翻转、重放幂等性,以及每个发出事件的序列化往返。
第一次运行就失败了,位置在这里:
def _decode(value, hint):
if isinstance(value, dict) and "__type__" in value:
return _decode_tagged(value, hint)
...
当 line_items 是 None 时,编码后的 payload 带的是一个普通 null。没有 tagged-dict 分支可走,None 到达 union 分支,选中第一个非 None 参数,然后尝试迭代它。一行代码修好了它:
if value is None:
return None
现在三千个随机场景全部守住不变量,一个带种子的 200-case 版本作为 tests/test_refunds_property.py 留在套件里。
基于示例的测试编码的是你已经在脑子里想好的用例。对于钱、分配和序列化,那些都是容易想到的。
有一个测试失败时给出了一个我没预料到的原因码:
issued(refund(pipeline, amount="1000.00", request_id="req-1"))
outcome = failed(refund(pipeline, amount="1.00", request_id="req-2"))
assert outcome.reason is RefundFailureReason.EXCEEDS_REFUNDABLE # got STATUS_NOT_REFUNDABLE
两个答案都有其合理性。全额退款一个订单,两件事同时为真:订单进入 REFUNDED 状态,而该状态不在 REFUNDABLE_STATUSES 里,同时也没有任何东西可退了。
策略文档一锤定音,而且是在模型这边。文档定义了验证顺序,要求在第一个失败处停下,status_not_refundable 在第 5 位,exceeds_refundable 在第 7 位。全额退款的订单触发了规则 5。我的测试错了,文档是对的,所以我改了测试,并加了一个单独的用例来properly exercise EXCEEDS_REFUNDABLE:先把 SKU A 全部退完,然后在 B 还有余额的情况下再要退更多 A。
另一个方向也值得思考,因为那才是合规模型会采取的方向。要让我的测试通过,它可以悄悄重排验证规则,或者扩大 REFUNDABLE_STATUSES。两种改法都能通过 review,并在六个月后埋下一个退款 bug。它没有迁就,而是指出了它遵循的是哪份文档。
过度验证是真的。我为一个没有用户的玩具仓库写了一个 3000 场景的属性测试架,按任何合理计算都显得多余。但它还是 catch 到了一个 74 个绿灯测试漏掉的崩溃。模型卡列为缺陷的那个特质,正是 catch 到这个 bug 的特质。在任何涉及钱的地方我都愿意做这个交换。但我不希望在 10 分钟任务上为此买单。
它先读再写,把测试当规格来对待。第一步是对测试文件做 grep,提取出契约期望的精确 API 面(refunded_amount、refundable_remaining、refunded_net_by_sku),而不是自己起名字然后迭代到匹配。很小的事,但这就是在真实仓库里有用和在 demo 里惊艳之间的主要区别。
它用了分配规则而不是近似它。简报说用 Money.allocate 不要手写比例,实现也确实是这么做的,包括跳过零值行以及对每个 SKU 的剩余金额做 clamp。随机运行中没有任何漂移。
它自己的测试代码是薄弱环节。8 个初始失败里 7 个是我写的契约里的 bug:测试在退货前没有先下单,所以得到 ORDER_NOT_FOUND。当模型同时出题和答题时,破碎的试卷仍然是破碎的。
仓库阅读大约需要十分钟,有趣的旋钮在 docs/REFUND_POLICY.md。改一改策略就能在同一个代码库上得到一个不同的考试:退库存的退款、不可退的税、需要按行分摊的重新入库费、不同的验证顺序。钱的约束条件不变。
要让另一个模型来做这件事,把 SPEC.md 作为唯一提示词交出去,禁止修改 tests/ 下的内容,并要求 pytest、ruff check . 和 mypy 全绿。开头那个 collection error 是故意的。模型必须从那些 import 尚不存在的符号的测试里推断出 API 面。
所以,它让我印象深刻吗?
在我真正关心的那个窄问题上,是的。面对一个规则在文档里、算术必须精确的代码库,它先读了所有内容,一次过给出了正确的实现,拒绝用悄悄破坏 spec 的方式去迁就一个本可以满足的测试,然后找出了我自己放进套件里的两个 bug。这是我在一个我必须维护的代码库里对一个 agent 期望的行为。
在基准测试假装回答的那个问题上,不,数字也不支持它。0.07 的差距横跨 163 人评的 203 道题,就是平局。我在一个我自己设计的代码库上做的一道自我测试,不是跃迁式进步的证据。我有的只是一个数据点,说明模型在我实际会用它的条件下表现良好,这比基准测试表格对我更有价值,但也远不如一个正经评估。
什么时候会用,什么时候不会
用在需要长期 agent 工作的场景,一直重发大系统 prompt 和不断增长的工具历史。这里的经济账比表面价格更划算:上线时报价每百万输入 0.834 美元、每百万输出 2.501 美元、每百万缓存输入 0.042 美元,缓存输入打 20 折。Agent 循环主要是重发,所以缓存价格才是决定你账单的数字。它在输出上比 GLM 5.3 便宜约 40%,比 Kimi K3 便宜约 6 倍。
当需要真正的长上下文时用它。1M token 比 GLM 5.3 能给的更多,而且这是没有更便宜的开源模型能匹配的主要特性。
如果 Apache 2.0 且无使用领域条款对你重要就用它,这对大多数公司都适用。
如果你的优化目标只是价格且不需要上下文就别用它。DeepSeek V4 Pro 输入约便宜一半,输出约便宜三分之一。那仍然是地板价,Hy4 不会比它更低。
对任何延迟敏感的场景都不要用它。OpenRouter 上线时列出最高 43 tok/s 吞吐量,有评测者测到 36。当人类在交互循环的另一端等待时,这很慢。
在把它固定到生产流量之前三思。模型卡称这是一个带已知问题发货的早期版本,preview 标签意味着 checkpoint 会被替换。
无论你走哪条路,有一个习惯我都会保留:让它写实现,自己 review 它写的测试。这次代码是对的,我的测试错了,七次。