编写反射
Agent 做两种事。一种是应对发生在它身上和身边的事,应对完它还在原处。另一种是主动去让一件原本不成立的事成立。前一种是反应层:一棵反射树,一直自己在运行,不经过模型。后一种是模型的事,由它的程序去做,做成就结束。树从不主动去做什么。
一个行为属于哪一种,看的是它怎么开始,不看它带来什么:反射之所以开始,是因为某个条件自己成立了(agent 受伤了,需要的东西出现在够得着的地方)。Agent 学会的做事方法,不论用多少遍,都记成模型调用的技能,不是节点。
一次运行是一个模型加上它启动的单元。模型写的程序是一个单元,程序用 spawn 启动的代码和反射的程序也是。各单元并排运行,各有自己的输出和结果。没有反射要求按时判断时,没有任何东西按时间运行:不设反射的任务根本没有时钟。
不经过模型就行动的东西是一棵树,一开始是空的。只有一个调用,它往树上放一个节点:
set_reflex(name, when, do=None, under=None, priority=0, every=None, again_seconds=0)节点说明什么时候轮到它,以及轮到时做什么。任何节点下面都可以挂节点。全部规则只有四条:
- 挂在另一个节点下面的节点,只有在上面那个节点的条件成立时才轮得到,并且先于上面那个节点:下面有节点在应对时,上面的节点自己不动。节点自己做的事,应对的是属于它这种情况、又不属于下面任何一个节点的那部分;下面的节点开始应对时,它让位。
- 同一处的节点里,靠前的从靠后的手里接管环境,做完再还回去。
- 节点应付的情况过去了,它才算办成。它的程序结束时情况还没过去,或者程序失败,由排在它后面的来接:先是同一处后面条件成立的节点,再是上面那个节点自己做的事;都没有,才把程序做了什么告诉模型。
- 告诉模型的节点和其他节点一样轮到:同一处排在它前面的节点、以及它下面的节点,都没有在应对时,它才告诉模型。
def step_back(): ...def refill(): ...
set_reflex("too_close", lambda view: view["distance"] < 0.2, step_back, priority=2)set_reflex("refill", lambda view: view["level"] < 0.2, refill, priority=1, again_seconds=10)- when 是一个看视图的函数:它成立期间轮到这个节点。也可以是一个事件名:该事件发生时由这个节点回应,此时不再告诉模型。
- do 是节点做的事:
- 一个程序(函数):轮到节点时作为单元启动。它的目的就是让节点的条件不再成立。只要还轮到它,程序结束后不早于
again_seconds再次启动。回应事件的程序如果带参数,会收到该事件。 - 一个事件名:每次轮到它时告诉模型一次。轮到它,是指它的条件成立,并且它下面的节点和同一处排在它前面的节点都没有在应对。所以告诉模型的节点,告诉的正是没有任何节点应对的那部分。
"stop",或环境命名的其他动作:结束正在运行的执行。这种节点在执行的每一步都会被判断。- 不写:只有它下面的节点应对。
- 一个程序(函数):轮到节点时作为单元启动。它的目的就是让节点的条件不再成立。只要还轮到它,程序结束后不早于
- under 让这个节点成为另一个节点那种情况里更窄的一种。只要那个节点的
when是条件,不管它自己做什么,都可以往它下面挂节点,而且不用重新设置它:树靠往下添加来长大。 - priority(整数,0 或更大)是节点自己的优先级,不管它在树的哪一层。两个都轮到的节点里,大的得到环境,相同时树里靠前的得到;程序的
at_priority(n)也是和这个数比。 - every 是判断其条件的间隔秒数。不写时,状态或事实变化时判断。
reflexes() 给出当前的树,每个节点带着条件是否成立、程序是否在运行、启动过几次,以及上一次运行的结束报告。drop_reflex(names=None) 移除节点及其下面的节点。设置一个已有名字的节点,新的取代旧的。
树要整体设计,不是出了什么事就长一块。模型不在看的时候,替 agent 应对的只有这棵树,它没覆盖的就没人管。依赖它之前,模型先说出 agent 将要所在的地方会发生在它身上的每一类事(它的状况、它脚下、周围冲它来的、它手头正在做的)。树把这一切划分开。一个节点就是一种情况:它的条件说明 agent 什么时候处在其中,依据的是 agent 身上正在发生什么,不是什么造成的。节点做的事是它对这种情况本身的应对:一个不管原因是什么都适用的做法,或者告诉模型(do="NAME")。它下面的节点是这种情况里更窄的、模型知道更好应对办法的情况,按原因或处境来分。下面有节点在应对时,这个节点自己不动;属于这种情况、又不属于下面任何一个的,由这个节点自己应对。所以一个节点从设下那一刻起就是完整的,以后学到的东西挂到它下面,不用改它:每个节点都这样写。每一层都是如此,一种情况分到多细取决于模型对它了解多少。树从上往下设计。先定最宽的情况,每一个是 agent 可能出问题的一个方面,各给一个节点;再把每个分成模型已经知道的更窄的情况。几个条件里共同的部分只判断一次,放在它们上面的节点里,它们各自只说自己在那里面的区别。宁可树深,不要树宽:挂在别的节点下面的节点,只有上面那个条件成立时才会被判断,所以上面安静的时候,下面的不花任何代价;而且宽的节点能接住模型没列出来的情况,一排并列的情况里则没有节点管它。新节点挂到它所属的最窄的那个节点下面,哪个都不属于时才放到顶层。意外有两种,树都要告诉模型。一种是发生了一件没有节点应对的事。另一种是该发生的没有发生:agent 一直在做,而这样做所要达到的效果一直不来;它本该改变的东西还是原样,时间和消耗却在流走。第二种和第一种一样,是 agent 身上的一种情况,而且正是最容易被放过的一种,因为什么都没有到来。所以不管启动了什么,都有一个节点盯着它是为了什么,当那个效果迟迟不出现、超出了一切如模型所想时该有的时间,就告诉模型。这两种都不允许既没有应对、模型也没听说。不管 agent 在做什么都会发生在它身上的事属于这棵树,即使是在某个程序里遇到的:只在那个程序里应对,别处就没有应对。
每一轮都会告诉模型树的近况,内容有上限,不随树变大:树里有多少个会行动的节点,以及上次告知以来有变化的节点各自的程序启动了几次、办成了几次、没办成几次(最多 10 个;空的树也会明说是空的)。reflexes() 给出顶层的节点,reflexes("name") 给出某个节点下面的节点,一次最多 50 个。
几个节点共用的东西
Section titled “几个节点共用的东西”只说一次,放在合适的地方:
set_reflex("underground", lambda view: view["depth"] > 0, "UNDERGROUND") # 下面都没应对的,告诉模型set_reflex("gas", lambda view: view["sensed"]["gas"], leave, under="underground", priority=2)set_reflex("dark", lambda view: view["light"] < 3, light_up, under="underground", priority=1)- 几个节点共有的条件是它们上面的节点:只判断一次,条件成立时才轮到它们;在地下、又既不是有毒气也不是太暗的情况,由上面那个节点自己做的事来应对。
- 几个节点共用的动作是它们调用的函数:复用靠普通代码。一个“在指定节点下设置一部分树”的函数,可以把同一部分放到多处。
- 状态里没有、它们又要看到的东西是感知量,定义一次,条件和程序都读。
- 它们知道的东西在事实里。
节点和模型程序的先后
Section titled “节点和模型程序的先后”节点的程序作为单元运行,单元的优先级就是节点自己的优先级。每个节点都在模型自己的程序(优先级 0)之上:反射从它们手里接管环境。模型主动要做的事是模型自己的程序,不是节点。
优先级高的单元需要环境时,低的那个调用怎么办取决于环境:
- 能暂停的环境(worker 的 profile 里声明
"pauses": True,并提供pause和resume:core/worker.py)让低的调用停在原地,并被告知是谁接管的,被保持的一方恢复时可以知道。高的单元行动,等它结束,调用接着进行,仍在同一个程序里,也不询问模型。trace 记录call_paused和call_resumed。 - 其他环境里,低的调用在当前位置以事件
PREEMPTED结束。它是节点的程序时,轮到该节点时节点自己重新开始。它是模型的程序时,模型立即得知并决定接下来做什么;用spawn(fn, restart=True)启动的单元由 harness 重新发起调用。
具身类型在 prompt_features 里列出 pauses,提示词据此说明适用哪一种。环境把输入划分为通道时,各单元在自己占用的通道上同时行动。
程序在反射中间的位置
Section titled “程序在反射中间的位置”模型的程序优先级是 0:任何反射都能从它手里接管环境。程序里某个不能被打断的动作,在它进行的这段时间里在反射中间占一个位置,只占这么久:
with at_priority(70): # 高于优先级低于 70 的反射,低于其余的 close_the_gate() # 只有这个动作watch_the_road() # 回到优先级 0:每个反射都轮得到在这个块里,程序以这个优先级持有环境。自身优先级为 70 或更高的反射仍然能从程序手里接管环境,不管这个节点在树的哪一层:比较的是实际运行的那个节点,不是它上面的节点。低于它的在此期间不行动:块结束时重新判断,模型会被告知一次它被挡住了(事件 HELD_BACK)。这个位置属于那个动作,不属于整个程序或某个阶段:占着它的时候,排在它下面的节点都不应对发生在 agent 身上的事,所以整段都占着位置的程序等于把树的工作揽到了自己身上。不在 with 里的 at_priority(n) 什么都不持有,并在程序的输出里说明。仍然会结束这个程序的有:取消它、调用超时、结束执行的节点、回合结束。
在环境里长时间运行的代码(agent 存在那里的例程)用同样的方式要位置:只要那段代码还在要,它的 worker 就在状态里报告 "priority",正在运行的那次调用所属的单元就以它持有环境(core/worker.py)。
条件能看到什么
Section titled “条件能看到什么”条件读的是视图:当前的状态、facts(view["facts"])、感知(view["sensed"]),以及单看状态看不出来的东西:
view["was"][key] |
状态的这一项在上一次变化之前是什么(感知在 ["sensed"] 下) |
view["unchanged"][key] |
它已经多少秒没有变化 |
view["units"] |
正在运行的单元,每个是 {"name", "kind", "reflex", "priority", "seconds"} |
有了这些,节点可以表达“某样东西在变坏”,或者“有程序在跑,而该变的没有变”。
单元怎样结束
Section titled “单元怎样结束”每个单元结束时都给出同一份报告,不论是谁启动的:模型的程序、它 spawn 的代码、节点的程序。
| 字段 | 含义 |
|---|---|
ended |
怎么结束的:自己跑完、出错、某个事件、被取消、到了时限。 |
done |
它要办的事办成了没有,按结束那一刻的视图判断。没有声明条件时为空。 |
returned、error、seconds |
它交回了什么,或为什么失败。 |
怎么结束和办没办成是两回事:自己跑完的单元,如果它要办的事没有成立,就没有办成。单元要办的事是它的完成条件,一个看视图的函数:
until(lambda view: view["door"] == "open") # 在程序里:这个程序要办成的事spawn(fill, until=lambda view: view["level"] > 0.8)节点的程序不需要写:它要办的事就是节点的条件不再成立。一次执行有它自己的完成条件(控制器的 until=)。
报告交给启动它的一方。模型的程序报告给模型。节点的程序报告给树,树只按 done 行事。
模型会被告知什么
Section titled “模型会被告知什么”树自己的程序是树自己的事:它们的结束只被记录(unit_end,开始时的 rule 记录),办成了的不引发对模型的调用。模型会被告知:
- 节点发出的事件(
do="NAME"); - 节点的程序结束时情况还没过去,或者程序失败,且同一处后面没有节点接手(事件
REFLEX_FAILED,带着程序的报告;在该节点再次办成之前只告知一次)。程序失败的节点 2 秒内不再开始; - 节点被不可打断的程序挡住;
- 没有节点回应的事件。
facts 是这次运行共享的所知:一个存放命名值的小仓库,程序读写它,条件通过 view["facts"] 读它。
facts.set("door", "open")facts.get("door", "closed")facts.drop("door")值是普通数据(数字、文本、列表、字典)。把进度放在事实里:被结束后重新启动的程序可以从已完成的地方继续,而不是从头开始。
条件只读视图、不做调用,所以它只看得到环境报上来的东西。当环境能在自己那边运行代码时(游戏里的 bot、页面里的脚本),模型可以定义环境还要报什么:感知量是一段有名字的、用环境自己的语言写的代码,环境每次报告状态时把它算出来。
sense("edge_ahead", "...环境自己语言的代码...") # {"value": ..., "error": ...}set_reflex("edge", lambda view: view["sensed"]["edge_ahead"], step_back)get_state()["sensed"]["edge_ahead"] # 程序里读到的是同一个值senses() # 已定义的感知量,含代码和当前值drop_sense("edge_ahead")同一个定义同时供条件和程序使用,判断也和原来一样快:条件被判断时,值已经在状态里。
worker 在 profile 里声明 "senses": True 并提供 sense、senses、drop_sense 三个方法(core/worker.py),它的状态里就带有 "sensed"。具身类型在 prompt_features 里列出 senses,它的提示词说明代码用什么语言、能用到什么。
run_controller(fn, until=None, max_seconds=None) 在每一步应用 fn(view) 返回的命令,用于视觉伺服和其他快速回路。它是一次有界执行:结束执行的节点对它有效,它以事件结束。
条件和控制器必须快,不能打开文件、使用网络、启动进程、睡眠或调用模型。它们每个周期共用一份预算 runtime.reactive_budget_ms(默认 5 毫秒)。某个周期里前面的节点把预算用完、没轮到判断的节点不算故障:它保持原来的结果,下一个周期再判断。条件偶尔一次超过预算是机器的一时波动,它的结果照常算数,被强制中止的(超过预算的四倍)则保持原来的结果、下一个周期再判断;连续三次超过预算,或者抛出异常、违反上述限制,才会产生 REACTIVE_FAULT 事件,它的节点连同下面的节点被移除。节点的程序是普通代码,没有这些限制。
每条命令仍要经过环境自己的检查,格式错误或超出范围的命令会被拒绝,不论是谁写的。延迟的命令在发出前可以被否决。一旦发出,回执属于发出它的执行。回执丢失不会引起自动重试。
在技能和库里
Section titled “在技能和库里”技能用同一个调用往会话的树 self.reactive 上放节点:
class Footing: def keep_footing(self): """前方地面没有时,结束一切移动。""" self.reactive.set("no_gap", lambda view: view["ground_ahead"] is False, "stop")库里的 reactive 条目就是设置其节点的那几行(set_reflex(...) 以及它们用到的函数):条目的测试通过后,会话开始时运行这些行。设置了 knowledge = "learned" 时,agent 开始时不带包里手写的反射,自己学(让它学习一个环境)。
