第 16 关
主动式智能体
- user
- assistant
- tool_result
EventBus
问题
有些工作根本不存在一个让人想起来要问的时机:主人在学校时饿了的宠物、卡住的订单、半夜开始出故障的服务器。一个只有被叫到才回应的智能体,对这些情况毫无用处。
显而易见的办法,是用一个定时器隔一段时间就运行一次智能体。做得马虎的话,这就是失控的布谷鸟:每次心跳都叫醒模型,每次运行都要花 token,每次运行都给你发一句“一切正常”。到第三条消息时你就不再看了,而真正要紧的那条也就没人读了。
解决方案
心跳:一个定时器,把一个固定的提示词 checkPrompt 交给智能体,就像有人打了这行字一样。让它有用而不是扰人的,是你在这个定时器周围加的东西:
-
叫醒之前先检查
localCondition
在每次心跳时、调用模型之前运行的普通代码:读一个指标、一个文件、一行数据。只要它说“不”,这次心跳就一分钱都不花。
-
一次只跑一个
内置
上一次运行还没结束时触发的心跳会被直接丢弃,而不是排队等待。两次运行永远不会同时共用历史记录。
-
保险丝
maxTicks, timeoutMs, runTimeoutMs
心跳次数、实际时长和单次运行时长各有一个预算,这样即使你忘了停掉它,它也会自己结束。
-
只说一次,然后关掉
stopHeartbeat()
只在真的发生了什么时才给人发消息,并说明结果如何。工作结束后,就把心跳关掉。
第一条承担了大部分工作。大多数心跳都发现没什么可做,而判断这一点用不着模型:一个 if 就够了。在动画里,五次心跳过去,只有一次调用了模型。
角色
还是那群熟悉的角色,这次住进了一只口袋宠物里。
- 时钟 心跳
- 它的闹铃每两小时响一次,心跳开着时就一直亮着。
- 括号 localCondition
- 每次心跳都在那些心周围闪一下:那是你自己的代码在读指标。不涉及任何模型。
- Astor 循环
- 在小垫子上打盹,直到某次心跳说有项指标偏低,然后像往常一样跑循环。
- 神谕者 模型
- 一直睡着,直到 Astor 把 checkPrompt 带给它。
- 图标 工具
- 状态、食物、游戏和呼叫灯:
check_status、feed、play和beep_owner。 - 主人 人
- 一整天都在学校。只收到一声哔,而且已经是好消息了。
在 EventBus 面板里,安静的心跳只有你的代码在运行:对于被你的检查否决的心跳,astorlm 不会发出任何事件。heartbeat_tick 只出现一次,就是模型真正被叫醒的时候。
代码
使用 astorlm:给智能体传入 heartbeat,它就会自己启动。各种防护都是选项:localCondition、maxTicks 和 timeoutMs;重叠的心跳会被自动丢弃。主人回来时,你的应用调用 stopHeartbeat()。
从零手写:在第 2 关的循环外面套一个定时器。用一个标志位防止运行重叠,用一个计数器和一个截止时间当保险丝,再用一个普通函数决定到底要不要调用模型。
import { OpenAIProvider, createLocalAgent, tool } from 'astorlm'
import { z } from 'zod'
const HOUR = 60 * 60_000
const checkStatus = tool({
name: 'check_status',
description: 'Open the status screen: hunger and happiness in hearts, and whether the pet is sick or asleep.',
schema: z.object({}),
execute: async () => pet.status(), // your code: the pet lives in your app
})
const feed = tool({
name: 'feed',
description: 'Feed the pet a meal or a snack. A meal fills hunger; a snack only cheers it up.',
schema: z.object({ food: z.enum(['meal', 'snack']) }),
execute: async ({ food }) => pet.feed(food),
})
const play = tool({
name: 'play',
description: 'Play the left-or-right game with the pet. Winning fills happiness.',
schema: z.object({}),
execute: async () => pet.play(),
})
const beepOwner = tool({
name: 'beep_owner',
description: 'Beep the owner with a short message. They are at school: only when something happened.',
schema: z.object({ text: z.string() }),
execute: async ({ text }) => {
await sendPush(text) // your code
return 'Beeped.'
},
})
const agent = await createLocalAgent({
// Any OpenAI-compatible endpoint: OpenAI, Ollama, LM Studio, vLLM, a proxy…
provider: new OpenAIProvider({
baseURL: 'http://localhost:11434/v1', // e.g. Ollama's default address
model: 'your-model', // e.g. 'llama3.1', 'gpt-4o-mini'
apiKey: 'YOUR_API_KEY', // local servers usually ignore it
}),
tools: [checkStatus, feed, play, beepOwner],
maxTurns: 8,
// Starts on its own as soon as the agent is created. Nobody types anything.
heartbeat: {
intervalMs: 2 * HOUR,
checkPrompt: 'Check on Milonga. Take care of whatever is low, then beep her owner with one line.',
// Runs on every tick, before the model. While it says no, a tick costs 0 tokens.
localCondition: () => pet.hunger <= 1 || pet.happy <= 1,
maxTicks: 6, // the fuses: a school day of ticks at most…
timeoutMs: 10 * HOUR, // …and of wall-clock time
},
})
// The owner is home: the app takes over and switches the heartbeat off.
onOwnerHome(() => agent.stopHeartbeat())
// Quiet ticks emit nothing. The ones that wake the model do:
agent.on('event', (event) => {
if (event.type === 'heartbeat_tick') console.log('heartbeat woke the agent')
})
// A heartbeat, from scratch. Plain fetch and timers, no SDK.
// Any OpenAI-compatible endpoint: OpenAI, Ollama, LM Studio, vLLM, a proxy…
const LLM = {
baseURL: 'http://localhost:11434/v1', // e.g. Ollama's default address
model: 'your-model', // e.g. 'llama3.1', 'gpt-4o-mini'
apiKey: 'YOUR_API_KEY', // local servers usually ignore it
}
const HOUR = 60 * 60_000
// 1. The tools, as in level 3. The pet lives in your app.
type ToolFn = (args: Record<string, string | number>) => Promise<string>
const tools: Record<string, ToolFn> = {
check_status: async () => pet.status(),
feed: async ({ food }) => pet.feed(String(food)),
play: async () => pet.play(),
beep_owner: async ({ text }) => {
await sendPush(String(text)) // your code
return 'Beeped.'
},
}
const toolSchemas = [/* one JSON Schema per tool: check_status(), feed(food), play(), beep_owner(text) */]
type ToolCall = { id: string; function: { name: string; arguments: string } }
type Message =
| { role: 'user'; content: string }
| { role: 'assistant'; content: string | null; tool_calls?: ToolCall[] }
| { role: 'tool'; tool_call_id: string; content: string }
// 2. The loop from level 2, unchanged. Each tick that gets through is one run of it.
async function runAgent(prompt: string, maxTurns = 8): Promise<string> {
const messages: Message[] = [{ role: 'user', content: prompt }]
for (let turn = 1; turn <= maxTurns; turn++) {
const res = await fetch(`${LLM.baseURL}/chat/completions`, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${LLM.apiKey}` },
body: JSON.stringify({ model: LLM.model, messages, tools: toolSchemas }),
})
const [choice] = (await res.json()).choices
const reply: Message = choice.message
messages.push(reply)
if (choice.finish_reason !== 'tool_calls') return reply.content ?? ''
for (const call of reply.tool_calls ?? []) {
const run = tools[call.function.name]
let output = `Unknown tool: ${call.function.name}`
try {
if (run) output = await run(JSON.parse(call.function.arguments))
} catch (err) {
output = `Error: ${err instanceof Error ? err.message : err}`
}
messages.push({ role: 'tool', tool_call_id: call.id, content: output })
}
}
throw new Error(`No answer after ${maxTurns} turns`)
}
// 3. The heartbeat: a timer, a cheap check before the model, and fuses.
const CHECK_PROMPT = 'Check on Milonga. Take care of whatever is low, then beep her owner with one line.'
const MAX_TICKS = 6
// Plain code, no model: while it says no, a tick costs 0 tokens.
const localCondition = (): boolean => pet.hunger <= 1 || pet.happy <= 1
let running = false
let ticks = 0
async function tick(): Promise<void> {
if (running) return // still busy with the last tick: skip this one, never overlap
if (++ticks > MAX_TICKS) return stop() // fuse: a budget of ticks
if (!localCondition()) return // nothing low: let the model sleep
running = true
try {
console.log(await runAgent(CHECK_PROMPT))
} catch (err) {
console.error('heartbeat run failed:', err) // log it, and let the next tick try again
} finally {
running = false
}
}
const timer = setInterval(tick, 2 * HOUR)
const deadline = setTimeout(stop, 10 * HOUR) // fuse: wall-clock time
function stop(): void {
clearInterval(timer)
clearTimeout(deadline)
}
// The owner is home: the app takes over.
onOwnerHome(stop)
# A heartbeat, from scratch. Standard library only, no SDK.
import json
import threading
import time
import urllib.request
# Any OpenAI-compatible endpoint: OpenAI, Ollama, LM Studio, vLLM, a proxy...
LLM = {
"base_url": "http://localhost:11434/v1", # e.g. Ollama's default address
"model": "your-model", # e.g. "llama3.1", "gpt-4o-mini"
"api_key": "YOUR_API_KEY", # local servers usually ignore it
}
HOUR = 60 * 60
def post(path, payload):
request = urllib.request.Request(
f"{LLM['base_url']}{path}",
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json", "Authorization": f"Bearer {LLM['api_key']}"},
)
with urllib.request.urlopen(request) as response:
return json.load(response)
# 1. The tools, as in level 3. The pet lives in your app.
def beep_owner(text):
send_push(text) # your code
return "Beeped."
TOOLS = {
"check_status": lambda: pet.status(),
"feed": lambda food: pet.feed(food),
"play": lambda: pet.play(),
"beep_owner": beep_owner,
}
TOOL_SCHEMAS = [...] # one JSON Schema per tool: check_status(), feed(food), play(), beep_owner(text)
# 2. The loop from level 2, unchanged. Each tick that gets through is one run of it.
def run_agent(prompt, max_turns=8):
messages = [{"role": "user", "content": prompt}]
for _ in range(max_turns):
choice = post("/chat/completions", {"model": LLM["model"], "messages": messages, "tools": TOOL_SCHEMAS})["choices"][0]
reply = choice["message"]
messages.append(reply)
if choice["finish_reason"] != "tool_calls":
return reply.get("content") or ""
for call in reply.get("tool_calls", []):
try:
output = TOOLS[call["function"]["name"]](**json.loads(call["function"]["arguments"]))
except Exception as err:
output = f"Error: {err}"
messages.append({"role": "tool", "tool_call_id": call["id"], "content": output})
raise RuntimeError(f"No answer after {max_turns} turns")
# 3. The heartbeat: a timer, a cheap check before the model, and fuses.
CHECK_PROMPT = "Check on Milonga. Take care of whatever is low, then beep her owner with one line."
INTERVAL = 2 * HOUR
MAX_TICKS = 6
DEADLINE = time.monotonic() + 10 * HOUR # fuse: wall-clock time
stopped = threading.Event()
on_owner_home(stopped.set) # the owner is home: the app takes over
def local_condition():
"""Plain code, no model: while it says no, a tick costs 0 tokens."""
return pet.hunger <= 1 or pet.happy <= 1
# One thread, one run at a time: a tick can never overlap the last one.
ticks = 0
while not stopped.wait(INTERVAL):
ticks += 1
if ticks > MAX_TICKS or time.monotonic() > DEADLINE:
break # the fuses
if not local_condition():
continue # nothing low: let the model sleep
try:
print(run_agent(CHECK_PROMPT))
except Exception as err:
print("heartbeat run failed:", err) # log it, and let the next tick try again
注意事项
- astorlm 的心跳只保留一个会话。每次叫醒模型的心跳都会往同一段历史记录里添东西,所以频繁叫醒模型的心跳,会让它的上下文和账单随每次运行一起增长。让 checkPrompt 保持简短、加上压缩,或者每次运行都换一个全新的智能体(上面的从零手写版本就是这么做的)。
-
留意单次运行的超时。
runTimeoutMs默认是 60 秒。如果某次运行的工具要等很慢的东西,就需要更长的时间,否则会在半路被中止。 - 心跳不是 cron 任务。它活在你的进程里:进程一停,心跳也停,错过的心跳不会补回来。对于必须挺过重启的工作,让一个真正的调度器来启动智能体,并保留同样的防护。
- 写提示词之前,先决定什么值得发消息。要写“如果你不得不做了什么,就通知我”,而不是“告诉我进展如何”。一条什么都没说的消息,只会让人学会忽略下一条。
- 独自行动同样需要边界。没人在看着。喂宠物没问题;任何撤不回来的事,都应该等一个人点头。