大额退款连审批队列都没进
目标
在无法撤销的事情面前让图停下,并在人修改数值或拒绝之后接着运行。把两种停下的方法都做一遍,并用亲自数出的数字确认两者的差别。
为什么重要
在 LangGraph 中,暂停是建立在保存之上的。暂停的意思是“不在现在执行下一步,而是在留下的状态上,稍后接着做”,所以没有留存的地方(checkpointer),就无法接着做。然而只给断点而没有 checkpointer,也不会报错——图会悄悄停住,返回只有一半的状态。在本实验中要先亲眼看到这一点。
暂停之后人做的事不只是批准。有时会砍低金额,有时会拒绝。用 update_state 修改的值会经过那个键的 Reducer 再写入,所以覆盖型的键和接续拼接型的键的行为不同。
停下的方法有两种。interrupt_before 在进入节点之前停下,节点内的 interrupt() 则在调用的那个位置停下。后者在恢复时该节点会从头再运行——这意味着放在前面的副作用会发生两次。本实验中亲自数一数进入了几次。
评分器不会相信你写下的说明。它会真正导入你的模块,用任意的金额和原因来运行,并把停下的位置和接着运行的结果与评分器另外计算的值对照。
步骤
- 在 /root/work/aghitl/approve.py 中创建
AUTO_LIMIT = 100000、RISKY、needs_approval(amount, reason)、State、三个节点(intake、auto_settle、settle)、triage和build_graph()。此时还不停下。 - 增加
build_forgotten()和run_forgotten(amount, reason)。运行没有 checkpointer 而只给了interrupt_before=["settle"]的版本,记下会发生什么。 - 增加
SAVER = MemorySaver()、build_pausing()、thread(name)、start_case(amount, reason, name),让它真正停下。start_case返回{"next": ["settle"], "outcome": "", "paid": -1}。 - 增加
resume_case(name),用invoke(None, config)接着运行。 - 用
resume_case(name, amount=..., decision=...),让它在人修改金额或拒绝之后接着运行。拒绝的话,一分钱也不会付出去。 - 增加
confirm节点、build_asking()和ask_and_answer(amount, reason, answer, name)。在节点内部用interrupt()停下,再用Command(resume=답)(占位符为人给出的答案)接着运行。用SIDE_EFFECTS统计进入了节点几次。 - 增加
handle(...),把一条处理到最后,并用一行留下请求值、执行值和是否批准。 - 在 /root/work/aghitl/approve_report.json 和 /root/work/aghitl/approve_report.md 中留下记录。
参考
- 执行契约:评分器会把
/root/work/aghitl/approve.py当作 Python 模块导入,直接使用上面列出的名称。不会作为脚本运行。 needs_approval(amount, reason)在金额超过AUTO_LIMIT或原因在RISKY之中时为真。RISKY = ("파손", "오배송")(韩文,意为“破损”“错发”)。- 状态键:
amount、reason、decision、paid、outcome、trace。trace使用接续拼接的 Reducer。节点名称和状态键不能重名——重名的话,编译时会出现ValueError: 'x' is already being used as a state key。 - 节点留下的值:
auto_settle是outcome="auto";settle在批准时是outcome="settled",状态中的decision为"reject"时是outcome="rejected"且paid为 0。批准时paid是那一时刻的amount。 - 写入
trace的名称与节点名称相同(intake、auto_settle、settle、confirm),人修改的痕迹写作human。 run_forgotten的答案:{"raised": 참거짓, "trace": [...], "outcome": 문자열, "resumable": 참거짓}(占位符依次为布尔值、字符串、布尔值)。自己运行一下,按看到的填写。start_case的答案:{"next": [...], "outcome": 문자열, "paid": 정수}(占位符依次为字符串、整数)。next是把get_state(config).next转成列表的结果。resume_case的答案:{"outcome": 문자열, "paid": 정수, "trace": [...]}(占位符依次为字符串、整数)。ask_and_answer的答案:{"next": [...], "asked": [...], "entered_before": 정수, "entered_after": 정수, "outcome": 문자열, "paid": 정수, "decision": 문자열}(占位符依次为整数、整数、字符串、整数、字符串)。asked是放在get_state(config).tasks的interrupts中的值。答案为"yes"就是批准,否则是拒绝。handle的答案:{"approval": "not_required"|"approved"|"rejected", "outcome": 문자열, "paid": 정수, "requested": 정수, "changed_by_human": 참거짓}(占位符依次为字符串、整数、整数、布尔值)。- 重复使用同一个
thread_id,之前的记录还在。评分器每次都会传入不同的名称。 - 用
update_state修改的值会让分支重新走一遍。那个值会作为最后运行的节点写入的值被记录,该节点的条件边会被重新评估。所以如果把等待审批的那一条的金额砍到AUTO_LIMIT以下,那一条就会离开审批路径,落到auto_settle。评分器只在上限之上砍低,所以本实验不涉及这种重新接线,但要知道这件事。 - 这个 Pod 没有互联网。langgraph 0.2.60 已经装好了。
- 官方文档:Interrupts · Persistence · Types 参考
- 常见错误:没有 checkpointer 只给断点、不给
thread_id、接着运行时又放入原来的输入(那样会从头再运行——请传入None)、把无法撤销的事放在interrupt()之前。
区分哪些事要人看
在 /root/work/aghitl/approve.py 中创建 AUTO_LIMIT = 100000、RISKY、needs_approval、State、三个节点、triage 和 build_graph()。小额且原因简单就走 auto_settle,否则走 settle。此时还不停下。
把标准写在 needs_approval 一处,修改策略就变成修改这个函数。triage 返回分支名称(auto、hold),通向哪个节点由路径映射决定。节点名称要取得与状态键不同。
漏掉 checkpointer 就会悄悄消失
增加 build_forgotten() 和 run_forgotten(amount, reason)。运行没有 checkpointer、只给了 interrupt_before=["settle"] 的版本,把是否抛了异常、走到了哪里、能否接着运行,记为 {"raised": ..., "trace": [...], "outcome": ..., "resumable": ...}。
自己运行一下,按看到的写下来。结果会与预期不同——这种意外就是这一步的全部意义。因为没有保存任何东西,所以也无法用 invoke(None, ...) 接着运行。它之所以危险,就在于日志里一个错误也没有。
真正停下
增加 SAVER = MemorySaver()、build_pausing()、thread(name)、start_case(amount, reason, name)。需要审批的条目在 settle 之前停下,start_case 返回 {"next": ["settle"], "outcome": "", "paid": -1}。
三样东西必须同时具备——checkpointer、thread_id、中断点。get_state(config).next 不为空,就意味着“还没结束”。小额的条目不会停下,而是直接结束,所以重点是存在一个不经过设有中断点的节点的分支。
从留下的地方接着运行
增加 resume_case(name),用 invoke(None, config) 接着运行。答案是 {"outcome": "settled", "paid": 금액, "trace": [...]}(占位符为金额)。
在输入位置传入 None,意思就是“没有新输入。从保存的地方接着做”。如果再次放入原来的输入,就会从头再运行,足迹变成两倍,一眼就能看出来。必须使用同一个 thread_id 才能接上。
审批人修改金额或拒绝
让 resume_case(name, amount=..., decision=...) 先用 update_state 修改状态,再接着运行。decision="reject" 时,outcome 为 rejected,paid 为 0。修改的痕迹以 human 留在 trace 里。
update_state 放入的值也要经过那个键的 Reducer。像 amount 这样的覆盖型键会被覆盖,而像 trace 这样的接续拼接型键会累积——所以很适合留下一行痕迹。拒绝也必须是一种结果。如果让审批人以“干脆不点”来拒绝,那一条就会永远留在队列里。
在节点内停下,该节点会运行两次
增加 confirm 节点、build_asking() 和 ask_and_answer(amount, reason, answer, name)。用 interrupt() 停下,用 Command(resume=답)(占位符为人给出的答案)接着运行,并用 SIDE_EFFECTS 统计进入了节点几次。
interrupt() 返回的值就是人给出的答案。恢复后该节点会从头再运行,所以放在 interrupt() 之前的事会发生两次——请亲自数一数来确认。停下时传出的值放在 get_state(config).tasks 里的 interrupts 中。
把请求的值和实际出去的值一起留下
增加 handle(amount, reason, name, amount_override=None, decision=None),把一条处理到最后,并返回 {"approval": ..., "outcome": ..., "paid": ..., "requested": ..., "changed_by_human": ...}。
审批记录的核心不是人的名字,而是变化的值。如果请求的金额与实际付出的金额不同,这一事实必须写在记录里,以后才能解释。不需要审批的条目记为 not_required,并且不停下。
记录看到了什么
在 /root/work/aghitl/approve_report.json 中写入 auto_limit、forgotten、small、large、trimmed、rejected、confirm_entered,在 /root/work/aghitl/approve_report.md 中用 ## 어디서 멈추기로 했나(韩文,意为“决定在哪里停下”)、## 체크포인터를 빠뜨리면(韩文,意为“漏掉 checkpointer 会怎样”)、## 사람이 고친 값은 어디로 들어가나(韩文,意为“人修改的值进入哪里”)、## 노드 밖에서 멈추기와 노드 안에서 멈추기(韩文,意为“在节点外停下与在节点内停下”)四节来写。
forgotten 原样放入 run_forgotten 的答案,small、large、trimmed、rejected 只放入 handle 的答案中的 approval、outcome、paid 三个键。confirm_entered 是 {"before": 정수, "after": 정수}(占位符为整数),是 ask_and_answer 统计出的值。不要编造,要运行后得到。