从预约改期看 Agent 工具粒度:把不变量留在权威服务
用户要把九点的预约改到十点。模型选择了"修改结束时间",接口返回成功,于是原本九点到十点的预约变成九点到十二点;资源占位仍是九点到十点。数据库里的两个时间字段各自都是合法整数,结束也晚于开始,业务状态却已经错了。
这个反例是虚构的。它不证明某个模型容易犯错,而是暴露一种接口边界:当"改期"被拆成数次可独立提交的字段更新,调用者必须自己知道哪些字段、记录和规则应一起变化。模型少调一次工具、下一次调用超时,或中途重新规划,都可能留下已提交的半更新。
本文只处理这个问题:怎样把"修改字段"收敛成由权威服务执行的一次预约改期能力。一个标准库 SQLite 内存例子会实际展示三种结果:弱 CRUD 接口提交了不一致状态、改期能力完整提交、发生资源冲突后回滚已经执行的第一条写入。

先把不能被拆开的业务事实写出来
例子规定同一日历中的预约固定持续 60 分钟,预约与本日历持有的资源占位必须一致,且同一资源不能重叠占用。它们是作者为演示设置的规则,不是预约系统的通用行业标准。
起始数据如下,时间都是同一天从零点开始的分钟数,区间采用左闭右开形式:
| 对象 | 预约时间 | 资源占位 | 版本 |
|---|---|---|---|
| 预约 1 | 540--600,即 09:00--10:00 | 540--600 | 0 |
| 预约 2 | 660--720,即 11:00--12:00 | 660--720 | 0 |
预约 1 可以改到 600--660,即 10:00--11:00;结束点恰好等于预约 2 的开始点,不构成重叠。改到 660--720 则冲突。这个定义决定 SQL 的交集条件是"已有开始小于新结束,并且已有结束大于新开始",不能把所有端点相等都判成冲突。
"不变量"在这里不是一个抽象口号,它对应可以检查的关系:结束减开始必须为 60;预约的开始、结束必须与它的占位记录一致;改期完成时不能侵入另一预约的占位。
这里既有单条记录内的约束,也有跨记录的一致性。单字段类型检查无法表达完整关系。即使把两个时间字段放到同一个更新请求里,若占位仍由另一个独立调用修改,问题也没有完全消失。
将一次错误更新展开成可核对的状态
弱入口执行的是 UPDATE appointment SET end_min=720 WHERE id=1。这个SQL没有碰开始时间,也没有碰占位表。因此,它的后果可以直接从数据算出:预约持续180分钟,占位持续60分钟,两份表示不再一致。用户要求的10:00--11:00根本还没有形成。返回"更新成功"若被界面翻译成"改期成功",就是把存储层一个操作的结果扩大成领域结果。
本例在实跑的Python3.13.5默认LEGACY事务控制下,使用 isolation_level=None 建立连接;Python包装层不隐式开启事务,没有显式事务的这条SQL按底层SQLite规则提交,而不是先等待模型完成后续调用。这个设置受事务控制模式影响,不能当成所有Python版本或连接配置的默认行为。Python sqlite3事务控制因此坏状态是已提交的,不是一个事务内部暂时尚未写完的中间步骤。脚本随后的查询断言实际看到了 ((540,720,0),(540,600));不是仅靠文字设想第二步可能丢失。
如果把下一次开始时间更新也加上,两个时间字段最终可能恢复60分钟,但在下一次调用到来之前,另一个读取者可能已经按旧占位安排工作,界面也可能展示错误时长。若继续用独立调用改占位,还要解释第三步失败怎么办。这里没有运行多客户端去观察这一窗口;半更新已提交这一事实,已经足以说明接口承诺不能靠"模型通常会继续下一步"来维持。
更简单的替代是让服务端一开始就接收完整新窗口,并保护相关写入。它仍可在内部执行普通SQL,不要求引入专用领域框架、消息系统或更多Agent。变化首先发生在谁负责完成整个业务动作,而不是技术栈是否足够复杂。

为什么一次领域动作能改变责任归属
考虑两种工具入口。
text
字段工具:update_appointment_field(id, field, value)
领域能力:reschedule_appointment(id, expected_version, new_start, new_end)
领域能力并非给 CRUD 换个好听名字。它要求拥有状态的服务端读当前预约、判断是否允许改期、校验完整时间窗口、检查资源占位,并把这次动作需要的写入作为一个整体处理。模型提出"把预约 1 改到 10 点到 11 点",不用也不应掌握两张内部表的更新顺序。
失败语义也随之改变。字段更新成功只能说明一条 SQL 完成;改期成功必须表示这项能力承诺的不变量得到保护。若资源冲突,返回明确拒绝,并维持原有完整状态。不能一边返回"改期失败",一边把预约时间留在新值、占位留在旧值。
让模型提供意图参数,让服务端产生结果状态
这次改期所需的输入只有预约标识、调用依据的版本和完整新窗口。开始与结束均以同一天的分钟数表示,所以600和660没有时区换算歧义;真实跨天预约应采用自己的时间契约,不能把这个分钟数格式不加修改地移到生产。60分钟政策来自服务端规则,而不是模型在参数里传一个"忽略时长校验"的开关。
输出同样需要限定范围。成功时返回预约标识、提交后的时间和版本,调用者可以把它与用户原意核对;拒绝时给稳定原因,不能只返回一个含糊的false。例子中的slot_conflict表示当前资源占用不允许这次改期,stale_version表示调用者依据的状态已旧,两种情况都不应该被模型解释为"请再把字段改一次"。它们需要刷新业务信息或重新选择时间,而不是绕过规则换一个低层入口。
这里没有建立JSON Schema、可信身份或用户确认协议。参数集合只是解释能力粒度,不是已经验证了一个公开可调用的安全API;代码函数也不会因为名字变成reschedule就自动获得这些能力。将领域规则放回服务端,是接入工作的一个明确部分,不能拿它代替其余工作已经完成的证明。
这个责任对确定性程序同样重要。传统客户端也可能在第二次调用前断网,不能因为调用者由工程师编写,就认为跨调用的半更新安全。Agent 动态组织调用让问题更显眼,却不是一致性规则存在的原因。

一个单文件例子:故意弱的字段接口与原子改期
示例把预约与占位放在一个 SQLite 数据库里,由同一个排程服务持有。它用显式 BEGIN IMMEDIATE、提交和回滚表达事务边界,没有 HTTP、MCP、模型和外部通知。
数据库保留 end_min > start_min 这一基础约束,却故意没有把"固定 60 分钟"全部编码为 SQL CHECK,便于复现遗留字段入口绕过领域规则的反例。这不是推荐弱化数据库。真实系统若能用 CHECK、唯一约束或其他数据库机制表达规则,应把它们作为防线;加上时长 CHECK 能阻止本例的第一种坏更新,但仍需处理预约与占位的跨记录一致性。
把以下代码保存为 reschedule_invariants.py,使用 Python 3 运行,无需安装数据库驱动或创建磁盘数据库。不要使用 python -O,否则 assert 自检会被移除。
python
"""Fictional single-owner calendar; in-memory SQL, no authentication or network."""
import json
import sqlite3
def state(db):
appointment = db.execute(
"SELECT start_min,end_min,version FROM appointment WHERE id=1"
).fetchone()
reservation = db.execute(
"SELECT start_min,end_min FROM reservation WHERE appointment_id=1"
).fetchone()
return appointment, reservation
def consistent(db):
appointment, reservation = state(db)
return (appointment[1] - appointment[0] == 60
and appointment[:2] == reservation)
def reschedule(db, appointment_id, expected_version, new_start, new_end):
db.execute("BEGIN IMMEDIATE")
try:
row = db.execute(
"SELECT status,version FROM appointment WHERE id=?",
(appointment_id,),
).fetchone()
if row is None or row[0] != "scheduled":
raise ValueError("not_reschedulable")
if row[1] != expected_version:
raise ValueError("stale_version")
if (type(new_start) is not int or type(new_end) is not int
or not 0 <= new_start < new_end <= 1440
or new_end - new_start != 60):
raise ValueError("invalid_time_window")
db.execute(
"UPDATE appointment SET start_min=?,end_min=?,version=version+1 "
"WHERE id=?", (new_start, new_end, appointment_id),
)
# Deliberately check after the first write so rejection exercises rollback.
occupied = db.execute(
"SELECT 1 FROM reservation WHERE appointment_id<>? "
"AND start_min<? AND end_min>?",
(appointment_id, new_end, new_start),
).fetchone()
if occupied:
raise ValueError("slot_conflict")
changed = db.execute(
"UPDATE reservation SET start_min=?,end_min=? "
"WHERE appointment_id=?", (new_start, new_end, appointment_id),
).rowcount
if changed != 1:
raise ValueError("reservation_missing")
db.commit()
return {"status": "succeeded", "appointment_id": appointment_id,
"start_min": new_start, "end_min": new_end,
"version": expected_version + 1}
except ValueError as error:
db.rollback()
return {"status": "rejected", "reason": str(error)}
except Exception:
db.rollback()
raise
def main():
db = sqlite3.connect(":memory:", isolation_level=None)
try:
db.executescript("""
CREATE TABLE appointment (
id INTEGER PRIMARY KEY, start_min INTEGER, end_min INTEGER,
status TEXT, version INTEGER,
CHECK (end_min > start_min)
);
CREATE TABLE reservation (
appointment_id INTEGER PRIMARY KEY,
start_min INTEGER, end_min INTEGER,
CHECK (end_min > start_min)
);
INSERT INTO appointment VALUES (1,540,600,'scheduled',0);
INSERT INTO appointment VALUES (2,660,720,'scheduled',0);
INSERT INTO reservation VALUES (1,540,600);
INSERT INTO reservation VALUES (2,660,720);
""")
assert consistent(db)
# Deliberately weak legacy CRUD commits just one model-selected field.
db.execute("UPDATE appointment SET end_min=720 WHERE id=1")
assert not consistent(db)
assert state(db) == ((540, 720, 0), (540, 600))
print("crud_half_update=committed; domain_consistent=false")
db.execute("UPDATE appointment SET end_min=600 WHERE id=1")
assert consistent(db)
result = reschedule(db, 1, 0, 600, 660)
assert result["status"] == "succeeded"
assert state(db) == ((600, 660, 1), (600, 660))
assert consistent(db) and not db.in_transaction
print(json.dumps(result, sort_keys=True))
before = state(db)
result = reschedule(db, 1, 1, 660, 720)
assert result == {"status": "rejected", "reason": "slot_conflict"}
assert state(db) == before and consistent(db)
assert not db.in_transaction
print("slot_conflict=rejected; first_write_rolled_back=true; version=1")
result = reschedule(db, 1, 0, 720, 780)
assert result["reason"] == "stale_version" and state(db) == before
result = reschedule(db, 1, 1, 720, 800)
assert result["reason"] == "invalid_time_window" and state(db) == before
result = reschedule(db, 99, 0, 720, 780)
assert result["reason"] == "not_reschedulable" and state(db) == before
print("stale_version,invalid_window,missing_id=rejected; state_unchanged=true")
print("fixture_only=true; production_auth=false; external_notifications=false")
finally:
db.close()
if __name__ == "__main__":
main()
在附带示例的包目录执行:
bash
python3 examples/reschedule_invariants.py
运行环境是本地 Python 3.13.5、SQLite 3.53.0,实际退出码为 0。标准输出如下:
text
crud_half_update=committed; domain_consistent=false
{"appointment_id": 1, "end_min": 660, "start_min": 600, "status": "succeeded", "version": 1}
slot_conflict=rejected; first_write_rolled_back=true; version=1
stale_version,invalid_window,missing_id=rejected; state_unchanged=true
fixture_only=true; production_auth=false; external_notifications=false
第一行对应一次已经提交的坏更新:预约变成 540--720,占位仍为 540--600。它通过基础 SQL 约束,却没通过业务关系检查。示例随即把这条构造记录恢复,才开始验证领域能力,避免把后续成功建立在损坏状态上。
第二行是成功改期的完整结果。预约与占位都变成 600--660,版本从 0 到 1;代码在提交后实际查询两者并断言一致。输入 appointment_id=1, expected_version=0, new_start=600, new_end=660 对应的返回就是输出中的结构化对象。它不允许调用者直接设置 status 或新版本号。
第三行展示真正发生过的回滚路径。能力先把预约写成 660--720,再发现这段时间被预约 2 占用,于是显式 rollback。运行后的查询证明,预约 1 和占位仍是 600--660,版本仍为 1。这比只在写入前拒绝坏参数多验证了一件事:已经进入事务的第一条写入没有泄漏为最终业务状态。
示例故意把冲突检查放在第一条写入之后,以便观察回滚。在实际实现中,可在同一受保护事务里尽早做可行性检查,减少无谓写入;真正不能省的是校验与提交之间的保护,以及任何失败时完整恢复的结果。
最后一组断言覆盖旧版本、非法时长和不存在的预约。它们被拒绝后,已成功的改期结果保持原样。脚本还在成功与资源冲突后检查连接已退出事务,避免把"函数返回"误当作事务一定结束。
通过输出以后,还要看提交前后究竟查了什么
把三个主要分支按预约1展开,会得到下面这份可复算结果。它是代码构造输入的查询值,不是线上业务样本:
| 分支 | 调用或写入 | 返回后预约 | 返回后占位 | 判断 |
|---|---|---|---|---|
| 弱字段写入 | 只改end为720 | 540--720,版本0 | 540--600 | 已提交,但领域不一致 |
| 完整改期 | 版本0,600--660 | 600--660,版本1 | 600--660 | 两份状态一致 |
| 资源冲突 | 版本1,660--720 | 600--660,版本1 | 600--660 | 拒绝且恢复第一条写入 |
成功分支必须同时看预约和占位,而不是只看函数返回的成功文案。冲突分支必须对比调用前的完整状态,而不是只检查某一个字段。若冲突后时间恢复了但版本多加一次,调用者仍可能看到不存在的业务变化;所以断言把版本也一起比较。
SQL中的更新顺序也有含义。资源冲突是在第一条UPDATE之后发现的,足以让回滚验证真的覆盖已发生写入;事务提交则放在占位更新且影响行数为1之后。占位不存在时会以reservation_missing拒绝;代码包含这一保护分支,但本次固定主程序没有额外构造该损坏输入,因此不把它算成本次实际覆盖的断言场景。异常处理还有回滚后重新抛出的通用路径,本次同样未注入磁盘或驱动故障。
这些区别能避免把"有异常处理代码"写成"所有异常已经测过"。当前证据支持三类主要结果和明确执行到的参数/版本拒绝,不支持任意数据库故障、所有受损初始状态或完整并发正确性。如果后续更改事务控制或表结构,就应重新跑这些状态断言,而不能只保留旧stdout当作当前代码的验证。

事务负责一起提交,领域代码负责判断什么状态值得提交
SQLite 官方文档说明显式事务可由 BEGIN 开始,并由 COMMIT 或 ROLLBACK 结束。本文使用这些机制保护一组本地写入;事务本身并不知道预约必须持续多久,是否应该允许改期。SQLite Transaction
若把那条坏的字段更新放进事务后立刻提交,结果仍然错误。原子性保证的是一组操作作为整体生效或撤销,不会自动创造业务规则。反过来,只在模型侧或调用前检查一次时长,却把后续写入拆开提交,也不足以保护整体状态。这里需要的是权威规则与事务边界一起存在。
示例采用一个连接、一套有限构造数据,没有做多客户端压测。BEGIN IMMEDIATE 用于开始写事务,实际部署中可能遇到锁竞争或 busy;本文没有实现繁忙重试、连接池和故障恢复,因此不能凭这个脚本声称并发服务已经可靠。版本检查阻止本例依据旧版本操作,却不等于完整的幂等协议。
同样,事务提交只确认这两张本地表达到了约定状态。它没有发送短信,没有占用远端会议室,也没有验证其他系统同步完成。回滚与事后补偿也应分清。本例的冲突发生在提交之前,rollback让本次事务内已经做的写入撤销;如果先提交预约,再去远端请求失败,之后发起一次反向业务动作,属于另一种流程,不能继续称为同一个本地事务已回滚。两者对调用者可见的中间状态、失败处理和结果承诺都不同。若产品承诺包括外部通知,应另外定义待通知或最终确认等结果语义,不能把本地成功扩张成所有副作用已经完成。
聚合边界决定哪些变化必须由同一处裁决
把预约与它的占位看成一个一致性单元,是这个案例的建模选择。DDD 原作者的参考资料用聚合来界定一组实体和值对象的规则,并把整体不变量的维护交给聚合根或指定机制。这给出了判断责任的出发点,没有规定"一张表就是一个聚合"或"一个工具就是一个微服务"。Eric Evans:DDD Reference,Aggregates
本例的排程服务同时拥有预约时间与本地资源占位,所以能用一个数据库事务处理它们。资源冲突检查也由这个单一权威边界执行。我们没有把"所有相关东西都塞进一个巨型聚合"当作结论。
如果预约服务只拥有预约,资源服务另有自己的权威状态,就不能用本文的本地事务假装实现跨服务原子改期。此时应由明确的领域协议协调占位与确认,说明中间状态、释放与失败结果,或重新评估所有权划分;让模型连续调用两个裸 CRUD 接口并不会解决这个边界问题。本文不实现分布式补偿教程。
同一项业务能力可以由多个内部组件参与;工具层仍应向调用者暴露明确的能力承诺。内部表新增索引、占位拆表或存储迁移,不必自动变成模型可写字段变化。这是把能力模型与持久化模型分开带来的直接收益,而不是要求为了 DDD 先拆更多服务。

粒度取舍最后落到这张映射上
围绕这一个预约改期场景,可以得到一份可直接用于接口评审的映射:
| 必须保护的事实 | 谁拥有裁决权 | 对应能力边界 |
|---|---|---|
| 新窗口符合本类预约时长与当前状态 | 排程领域服务 | 在 reschedule 内整体校验,不开放任意时间字段写入 |
| 预约时间与本地占位一致 | 持有两者的同一权威边界 | 作为一次完整提交或完整拒绝处理 |
| 资源没有重叠占用 | 资源占位权威方;本例与排程同属一处 | 保护检查到提交的窗口;跨所有者时显式协调 |
| 调用依据的版本仍有效 | 当前预约状态所有者 | 拒绝旧版本,不能静默覆盖 |
| 返回是否只承诺本地完成 | 能力定义者与对应状态所有者 | 输出写明完成范围,不替外部通知作保证 |
逐字段工具过细,会把维持这些关系的责任交回模型;一个 manage_calendar 同时支持改期、删除、退款和管理员修复又太粗,难以说明权限与后果。较合适的单位是一个稳定、可被用户理解的业务意图,以及它必须共同保护的规则。
这不意味着所有 CRUD 都要删除。内部持久化仍然可以使用 CRUD,查询接口也可以按调用者需要组织。若某个低风险属性确实可以独立修改,独立修改能力可能就是最简单的设计。要收回的是会绕过不变量的任意写入口,而不是为了名称统一包装所有接口。
对已有系统,可以先找一项已经出现半更新风险的动作,沿着它读取的状态和必须共同改变的记录,画出最小边界。若排程服务仍允许模型绕过reschedule去写相同时间字段,新的领域入口只是一条可选路径,保护并没有闭合;内部运维修复若确需低层写入,也应有自己的受控路径,而不作为普通改期工具开放。
这份边界也能指导测试位置。模型侧可以验证它是否选择了改期能力、是否准确表达新时间,但不变量测试应留在每个调用者都要经过的服务端。未来把Agent换成网页、批量脚本或人工后台时,同一条规则仍然成立,才能说明保护绑定的是业务能力,而不是某一条提示词。
本文只给 reschedule 保留最小输入和结构化拒绝,不展开身份认证、JSON Schema、用户确认与幂等存储。真实工具链仍需按业务风险补齐这些保护;这段内存函数没有这些能力,不能直接作为生产 Agent 工具暴露。本文的内存演示仅证明列出的状态约束与事务行为,正式接入前仍须完成相应服务端保护。
拿现有工具目录做评审时,最后留下的应是本例这样的映射:这一组必须共同成立的业务事实,由哪一个权威边界裁决,工具对调用者承诺何种完成范围。把这个答案写清,再决定暴露什么参数;无需让模型先认识内部表结构,才能完成用户的一次改期。