post: stop writing agent prompts for deterministic work (EN+ZH) + og/banner images
Deploy / build (push) Successful in 14s

This commit is contained in:
2026-10-01 01:38:58 +08:00
parent f2d9c20207
commit 1ec97706bb
6 changed files with 171 additions and 0 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

+21
View File
@@ -1070,6 +1070,27 @@ BANNERS['loop-engineering-without-a-coding-agent'] = {
],
};
BANNERS['stop-writing-agent-prompts-for-deterministic-work'] = {
titlebar: 'root@hermes — deterministic check, no model',
lines: [
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'registry.npmjs.org/<pkg>/latest vs local version' },
{ t: 'prompt', text: 'WARN' }, { t: 'err', text: 'agent mode: [drift_skip] × 29 — died before it could speak' },
{ t: 'prompt', text: '' }, { t: 'dim', text: 'silent-because-healthy == silent-because-crashed' },
{ t: 'prompt', text: '$' }, { t: 'cmd', text: 'rewrite: exit 0 = measured · exit 1 = CANNOT MEASURE' },
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'script stdout delivered as the notice · no LLM in the path' },
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'the test: write down what success AND failure print' },
{ t: 'prompt', text: 'INFO' }, { t: 'cmd', text: 'number or string → script · "it depends" → model + verifier' },
{ t: 'prompt', text: '' }, { t: 'ok', text: '→ 8 of 11 loops now carry no model ✓' },
],
flow: [
{ n: '1', label: 'compare' },
{ n: '2', label: 'agent ✗', err: true },
{ n: '3', label: 'script' },
{ n: '4', label: 'exit 1 ≠ silent' },
{ n: '5', label: 'green ✓' },
],
};
// ---------- read frontmatter ----------
const postPath = join(ROOT, 'src', 'content', 'posts', `${slug}.md`);
let category = 'devops';
+5
View File
@@ -326,6 +326,11 @@ TERMINALS['loop-engineering-without-a-coding-agent'] = `
<div class="line"><span class="prompt">&nbsp;</span><span class="err">agent-mode job: [drift_skip] × 29 runs — silent broken and silent healthy</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">rewrite as a script · exit 1 = CANNOT MEASURE</span><span class="fix">→ 1,618 ticks ✓</span></div>`;
TERMINALS['stop-writing-agent-prompts-for-deterministic-work'] = `
<div class="line"><span class="prompt">$</span><span class="cmd">hermes cron: CLI version check · agent mode</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="err">[drift_skip] × 29 runs — silent, and silence reads as "nothing to report"</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">rewrite as a script · stdout IS the notification</span><span class="fix">→ green since ✓</span></div>`;
// ---------- read frontmatter ----------
const postPath = join(ROOT, 'src', 'content', 'posts', `${slug}.md`);
if (!existsSync(postPath)) {
@@ -0,0 +1,91 @@
---
title: "Stop Writing Agent Prompts for Deterministic Work"
description: "One of my scheduled jobs failed 29 times in a row because I put a model in a loop that only needed a string comparison. Here is the rule I use now."
pubDate: 2026-10-01
category: notes
tags: ["loop-engineering", "cron", "ai-agents", "automation"]
ogImage: "/og/stop-writing-agent-prompts-for-deterministic-work.png"
banner: "/banners/stop-writing-agent-prompts-for-deterministic-work.png"
draft: false
---
One of my scheduled jobs failed 29 times in a row. Nobody told me, because
it was designed to speak only when it had something to report — and a run
that dies before it can speak looks exactly like a run that found nothing
wrong.
The job was a version check: ask npm for the latest published version of a
CLI I use, compare it with the one installed here, and tell me if there is a
newer one. That is the entire task. It is two strings and an inequality.
I had built it as an agent job, because that was the pattern I had in my
head at the time. Every tick, the model would read the injected script
output and decide whether to write a notification. It worked for a while.
Then my global default model changed, and the job — which records the model
it was created with — stopped running entirely. It printed `[drift_skip]`
and exited. Twenty-nine times.
The fix was not a better prompt. It was deleting the model from the loop.
## The rule
**If the stop condition is a comparison — a string, a number, a status code — a model in that loop can only add failure modes.**
There is no judgment to make in "is `3.8.50` newer than `3.8.50`". The model
was not deciding anything the code could not; it was adding a dependency (a
model route, a provider, a config snapshot, a token bill) and a new way to
fail. So I rewrote the checker as 125 lines of Python with an explicit exit
path for every branch, and flipped the job to script mode: the script's
stdout is delivered as the notification, with no LLM anywhere in the path.
It has run clean ever since. Anthropic's own guidance says the same thing in
one line — *use scripts for deterministic work; running a script is cheaper
than reasoning through the steps* — but the cost was never the interesting
part for me. The reliability was.
## Where the model does earn its place
Of the eleven loops I run, eight now have no model in the path. The three
that do all handle something I genuinely cannot express as code:
- **Is this email important?** A mailbox produces promos, DMARC reports, container alerts and a customer asking for a quote. Fetching mail is deterministic. Ranking it is not.
- **Is this shop voucher about to overspend?** The arithmetic is trivial; deciding what a finding means for money I have already committed is not.
- **Is this project stalled?** Reading two markdown files is a script's job. Noticing that the plan has not moved in three weeks is not.
The test I apply to a new loop: **write down what the check would print on
success and on failure.** If both answers are a number or a string, it is a
script. If the answer is "it depends what it says", it needs a model — and
then it needs a verifier I trust more than the agent, because I am no longer
able to predict the output.
## The part that actually bit me
The failure mode was not the drift. It was the **silence**.
A loop that speaks only when something is wrong is the only kind of loop I
can tolerate — I do not want a daily status report from eleven jobs. But
"silent because healthy" and "silent because it crashed before it could
speak" are the same observation from my side of the phone. Twenty-nine runs
of evidence were sitting in the job's execution log the whole time; I was
trusting the absence of a notification instead of reading the history.
So every script in the fleet now has a second exit path: exit 0 means
*measured, here is what I found*, and exit 1 means *I could not measure* —
and exit 1 is never silent. Missing config, unparseable state, an unexpected
exception: all of them print a line and exit 1. That one change would have
surfaced this bug on the first run instead of the twenty-ninth.
If you have a loop that has been "quiet for a while", go and read its last
ten runs before you conclude it is working. That advice cost me three weeks.
## The short version
- Deterministic check → script. Judgment → model. Do not mix them for convenience.
- Give the silent-on-success convention one exception: "I could not measure" must always be loud.
- Read the runner's own execution history, not just your notifications.
Want a version of this for your own site or shop — uptime, certificates,
backups, mail that only pings you when it matters?
**[WhatsApp +60 12-797 2969](https://wa.me/60127972969)** ·
**[[email protected]](mailto:[email protected]?subject=Self-hosted%20monitoring)** ·
**[hoelee.com](https://hoelee.com)**
@@ -0,0 +1,54 @@
---
title: "确定性的活,别再用 Agent Prompt 去跑"
description: "我有一个定时任务连续失败 29 次,原因是我在一个只需要字符串比较的循环里塞了个模型。这是我现在用的规则。"
pubDate: 2026-10-01
category: notes
tags: ["loop-engineering", "cron", "ai-agents", "automation"]
ogImage: "/og/stop-writing-agent-prompts-for-deterministic-work.png"
banner: "/banners/stop-writing-agent-prompts-for-deterministic-work.png"
draft: false
---
我有一个定时任务连续失败了 29 次。没人告诉我,因为它被设计成只在有东西可报的时候才开口——而一个还没来得及开口就崩掉的运行,看起来和一个「什么都没发现」的运行一模一样。
那个任务是个版本检查:问 npm 某个我常用的 CLI 最新发布版本是多少,和本机装的版本比一下,有更新就告诉我。整个任务就这些。它就是两个字符串加一次比较。
我当初把它做成了 agent 任务,因为那阵子我脑子里的默认模式就是这个。每次 tick,模型读一遍注入的脚本输出,然后判断要不要写一条通知。它正常跑了一阵子。后来我改了全局默认模型,而这个任务会记录自己创建时的模型,于是它干脆整个不跑了,打印一行 `[drift_skip]` 就退出。连续 29 次。
修法不是写一个更好的 prompt,而是把模型从循环里删掉。
## 规则
**如果停止条件是一次比较——一个字符串、一个数字、一个状态码——那么循环里的模型只能带来新的失败模式。**
「`3.8.50` 是不是比 `3.8.50` 新」这件事里没有任何判断可做。模型并没有在决定代码决定不了的事;它带来的是一条依赖(一条模型路由、一个提供商、一份配置快照、一张 token 账单)和一种全新的出错方式。所以我把它重写成 125 行 Python,每条分支都有明确出口,再把任务切成脚本模式:脚本的 stdout 直接作为通知内容,整条路径上没有任何 LLM。
从此一路干净。Anthropic 自己的建议也是一句话——*确定性的活用脚本,跑脚本比让模型去推理步骤便宜*——但对我来说成本从来不是重点,可靠性才是。
## 模型该用在哪
我跑的 11 个循环里,现在有 8 个整条路径上没有模型。用模型的那 3 个,处理的都是我确实写不成代码的东西:
- **这封邮件重不重要?** 一个邮箱里同时有促销、DMARC 报告、容器告警,和一位来问报价的客户。抓邮件是确定性的,给它排优先级不是。
- **这张店铺优惠券是不是快超支了?** 算术很简单;判断一个发现对我已经承诺出去的钱意味着什么,不简单。
- **这个项目是不是卡住了?** 读两个 markdown 文件是脚本的活。发现「计划已经三周没动过」不是。
我给新循环做的测试是:**写下这个检查在成功和失败时分别会打印什么。** 如果两个答案都是一个数字或一个字符串,那它就是脚本。如果答案是「要看它说了什么」,那它需要模型——而这时候它就需要一个我比 agent 更信任的验证器,因为我已经无法预测输出了。
## 真正咬到我的是哪一部分
失败模式不是 drift,是**沉默**。
一个只在出事时才开口的循环,是我唯一能忍受的那种——我不想让 11 个任务每天给我发状态报告。但「健康所以安静」和「崩了所以来不及开口」,从我手机这端看是同一个现象。29 次运行的证据一直躺在那个任务的执行日志里;我却一直在相信「没有通知」这件事本身,而没去读历史。
所以现在这套脚本都多了一条出口:退出码 0 表示*量过了,这是结果*,退出码 1 表示*我量不了*——而退出码 1 永远不静默。配置缺失、状态文件解析失败、没预料到的异常:全都打印一行并退出 1。就这一处改动,本该让这个 bug 在第 1 次就暴露,而不是第 29 次。
如果你有一个循环「已经安静一阵子」了,先去翻它最近十次运行,再下结论说它正常。这条建议花了我三个星期才换到。
## 简短版
- 确定性的检查 → 脚本。判断 → 模型。别为了方便把两者混在一起。
- 给「成功则静默」这条约定开一个例外:「我量不了」必须永远大声。
- 去读 runner 自己的执行历史,而不是只看你收到的通知。
想给你的网站或网店也做一套这样的东西——可用性、证书、备份、以及只在真的有事时才 ping 你的邮件?**[WhatsApp +60 12-797 2969](https://wa.me/60127972969)** · **[[email protected]](mailto:[email protected]?subject=Self-hosted%20monitoring)** · **[hoelee.com](https://hoelee.com)**