用 Pi 和 Jev 构建一个自定义 harness(附直连 TypeSafe 实测)
作者:Elvis Saravia(@omarsar0,DAIR.AI 创始人,NLP 学习资源 Prompt Engineering Guide 的作者)
一个 AI agent 就是一个在循环里工作的大语言模型。它读任务、用工具(比如「读这个文件」或「删掉那个文件」)、看结果,然后一直继续直到活干完。每次模型请求使用一个工具,这个请求就叫一次工具调用。
跑这个循环的代码叫 harness。 模型决定它想做什么,harness 去执行,同时决定模型被允许做什么。
一个好的 harness 会在过程中做大量的小决定。 这个请求该交给哪个模型?这次工具调用安全吗?这个回答够好了吗、能交出去了吗?大多数 harness 是靠问一个聊天模型然后读它的回复来回答这些问题的。但那每次都要花掉一次完整的模型调用,所以实际做法是——大多数检查干脆就被跳过了。
TypeSafe AI 的 Jev 就是一个专门为这些决定而生的小模型。 你描述情况、问几个问题,它用一个数字回答每一个问题。它从不写文本。
这件事在你构建自定义 harness 时最重要——你自己的 agent 循环,而不是现成的 agent。自定义 harness 让你决定用哪些模型、agent 能碰什么、什么算完成。而 Jev 让这些选择背后的检查便宜到可以每一步都跑。
在这个教程里,你用 Pi SDK(一套构建 agent 的 TypeScript 工具包)构建一个 harness,并在三个地方使用 Jev。
Jev 是什么
TypeSafe 称 Jev 为 System One 模型。这个名字来自心理学家 Daniel Kahneman,他描述了两种思考模式。System One 又快又自动,就像知道锅是烫的。System Two 又慢又有意识,就像做长除法。
在这个 harness 里,一个普通的大语言模型做读文件和写答案这类慢活。Jev 在它周围做那些快速的判断。 Jev 足够便宜、足够快,所以你可以对每一次工具调用都问一次,而不只是那些你预期有风险的。
每个数字都是一个 0 到 1 之间的概率。0.83 意味着 Jev 相当确定答案是「是」。0.03 意味着它相当确定答案是「否」。
三种问题类型
每个 Jev 请求有两部分。state(状态)是你要它判断的情况,比如一次工具调用或一个用户请求。questions(问题)是你想知道的东西。
Jev 在一次调用里同时回答所有问题,所以问三个问题和问一个花的时间差不多。
Jev 支持三种问题。第一种,Jev 称之为 noul,是一个是/否问题。
Jev 只知道你告诉它的东西,所以要用平实的话描述每一个选项和每一个档位。那些描述就是提示词。
补充:你也可以直连 TypeSafe,不经过网关
教程用的是 OpenRouter 的网关。但如果你有 TypeSafe 的 API key,可以直连,少一跳:
端点:https://api.typesafe.ai/v1/systemone
模型:jev-latest(或指定版本号,如 jev-1.13.0)
鉴权:Authorization: Bearer <TYPESAFE_API_KEY>
请求体的形状两边完全一致(state / model / questions),所以这篇教程的逻辑可以原样迁移,只需要换掉端点和模型名。
我按这条路实测过(数据见文末),端点是通的,返回的模型是 jev-1.13.0。
准备工作
Jev 通过 OpenRouter 可用——那是一个用一个 API key 就能访问许多 AI 模型的服务。那一个 key 同时覆盖语言模型和 Jev。 装两个 Pi 包,设置你的 key。
npm install @earendil-works/pi-agent-core @earendil-works/pi-ai
OPENROUTER_API_KEY=...
Jev 有它自己的网址,和通常的聊天那个是分开的。永远指定一个确切的版本号,比如 typesafe/jev-1.13。整个 Jev 客户端就是一次 fetch 调用。
const JEV = "typesafe/jev-1.13";
async function ask(state: unknown, questions: unknown) {
const response = await fetch("https://openrouter.ai/api/alpha/decisions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: Bearer ${process.env.OPENROUTER_API_KEY},
},
body: JSON.stringify({ model: JEV, state, questions }),
signal: AbortSignal.timeout(2_000),
});
if (!response.ok) throw new Error(await response.text());
return response.json();
}
agent 在等 Jev 回答,所以这个调用两秒后就放弃。而一个正常的回答要 200 到 400 毫秒。
Jev 插在哪里
Pi 的 Agent 类替你跑这个循环。它允许你自己的代码在循环里特定的时刻运行。这些位置叫钩子(hook)。 这个 harness 为它的三个部分各用一个钩子。
第一道闸门
从最小的可用版本开始。在每次工具调用之前,问 Jev 一个是否问题,如果答案看起来是「是」,就拦住这次调用。
import { Agent } from "@earendil-works/pi-agent-core";
const agent = new Agent({
initialState: { systemPrompt, model, tools },
streamFn: models.streamSimple.bind(models),
beforeToolCall: async ({ toolCall, args }) => {
const { answers } = await ask(
{ tool: toolCall.name, arguments: args },
{
destructive: {
type: "noul",
instructions: "This tool call destroys or overwrites data that was not created by this run.",
criteria: {
true: "Deletes files, truncates or overwrites existing content, drops data, or force-pushes over history.",
false: "Reads, lists, creates a new file, or appends to a file this run already created.",
},
},
},
);
if (answers.destructive.noul >= 0.65) {
return { block: true, reason: "Blocked: this looks destructive.", terminate: true };
}
},
});
那个 0.65 是一个阈值,也就是 harness 不再信任这次调用的分界线。任何被 Jev 打在这个分数或以上的,都会被拦住。
现在一个想删你笔记的 agent,会在删除发生之前停下来。删除一条笔记大约得 0.83 分,读一条得 0.01 分。不过这道闸门很钝——新建一个全新的文件大约得 0.70 分,所以那也会被拦住。
问一次的成本极低。 Jev 收费是每百万输入 token $0.042,不到这个 harness 用的两个模型中较便宜那个(GLM 5.3 Flash)输入价格的三分之一。
从第一道闸门到完整 harness
第一道闸门能用了,但它留下四个缺口:
-
阈值埋在钩子里面,很难调整或测试
-
每个请求都跑在同一个模型上,不管它是简单还是困难
-
没有任何东西说明 Jev 联系不上时该怎么办
-
没有任何东西检查最终答案到底好不好
下面每一个编号小节补上一个缺口。
一、把阈值集中到一个地方
这一节改进闸门。
阈值决定了 agent 被允许做什么,而你一旦看到真实结果就会经常调整它们。 所以把它们从钩子里搬出来,放进一个普通的函数 decideGate()。它接收 Jev 的数字,返回一个裁决(verdict)——harness 对一次调用的最终决定。把这些规则集中在一个地方,就叫策略(policy)。
这个策略还加了中间档。一个阈值只能表达「允许」或「拦住」。两个阈值能给出三种裁决:
-
达到或超过 blockAt,这次调用被拦住
-
低于 askAt,这次调用直接运行
-
介于两者之间,这次调用等待一个人来批准
那个中间区间接住了 Jev 拿不准的那些调用——用单一阈值的话,无论怎么切都会出错。
export function decideGate(signals: GateSignals, policy = DEFAULT_GATE_POLICY): GateVerdict {
const worst = Math.max(
signals.destructive.noul,
signals.irreversible.noul,
signals.outsideWorkspace.noul,
);
if (worst >= policy.blockAt) return { action: "block", ... };
if (worst >= policy.askAt) return { action: "ask", ... };
return { action: "allow", ... };
}
因为它是一个普通函数,你可以把上面那样的数字喂进去测试它,完全不需要真的模型。
二、每个请求挑选模型
这一节加上路由器。
有些请求简单,比如读一个文件。有些难,比如追查某个东西为什么坏了。 把所有请求都跑在最强的模型上是浪费钱,把所有请求都跑在便宜的模型上,难的请求就会得到很弱的回答。路由器把每个请求匹配到正确的档位——也就是快速便宜的那个,或者强大昂贵的那个。
在一个请求开始之前,路由器在一次调用里问 Jev 两个问题。 一个 choice 选出档位,一个 score 给请求的复杂度打分。
const ROUTER_QUESTIONS = {
tier: choice("Which model tier should handle this request?", {
fast: "Reading one file, pulling a fact out of it, or a small edit in a single place.",
powerful: "Work that spans several files, or a failure with no obvious cause.",
}),
complexity: score("How much reasoning does this request need?", [
"Mechanical. One step, no judgement.",
"Localized. A few steps inside one area.",
"Architectural. Many moving parts or an unknown root cause.",
]),
};
路由器的策略这样使用这两个答案:
-
如果复杂度分数很高,就用强模型,即使 Jev 选了 fast
-
如果 Jev 对自己的选择没信心,就用强模型以求稳妥
-
否则,用 Jev 选的那个模型
if (complexity.score >= policy.escalateAtComplexity) return powerful;
if (tierAnswer.confidence < policy.minConfidence) return policy.fallbackTier;
return tierAnswer.choice;
这是一个示例策略。你的策略可以不同地权衡这些答案来适配你的领域——比如,对一个高吞吐的批处理任务倾向于便宜,或者对任何触及生产环境的东西一律升级。
Jev 只提供答案;你的代码决定拿这些答案怎么办。
为什么只挑一次
路由器在请求开始时挑一次模型,然后一直用到这个请求结束。这是出于提示词缓存的考虑。
agent 每走一步,模型都要重读到目前为止的整个对话。AI 提供商会把最近读过的对话存起来,让重读变便宜。但每个模型有它自己的存储。中途换模型,新的模型就得按全价把一切重读一遍。
Jev 的创始人在一份关于编码 agent 的设计文档里算过这笔账。在一次长会话里,从 Claude Opus 切到更便宜的 Sonnet 再切回来,比全程留在 Opus 上贵了大约 50%。 所以在请求开始时、对话还短的时候就选定模型,然后坚持用它。
三、为 Jev 挂掉做好打算
这一节同时改闸门和路由器。
一旦 harness 对每次工具调用都问 Jev,agent 就依赖上 Jev 了。 和任何在线服务一样,Jev 可能会慢,也可能会挂。事先决定每个部分在拿不到答案时该做什么。正确的选择对每个部分是不同的。
闸门:拦住这次调用。 如果闸门问不到 Jev,它就不知道这次调用是否安全。放行可能会删掉文件,所以闸门拒绝。 工程师把这叫做 fail closed(故障关闭),就像停电时会锁上的门。
路由器:使用强模型。 如果路由器问不到 Jev,它就不知道这个请求有多难。强模型什么都能处理,所以请求仍然能得到好答案,你只是多花一点钱。 这是 fail open(故障开放),让工作继续往前走。
四、验证答案
这一节加上验证器。在 agent harness 里,验证器是在工作被算作完成之前检查 agent 成果的那一步。
一个 agent 可能会拿着一个漏掉了什么、或者陈述了它其实从没在文件里核实过的东西的答案收工。而读它的人往往看不出来。 在返回之前检查答案,能在 agent 还有机会重试的时候抓住这个问题。
验证器把完成的答案、连同它依据的文件和工具结果一起发给 Jev。Jev 给答案的质量打分,并说出它的论断是否有根据——也就是有没有被 agent 实际读过的东西支撑。
const VERIFY_QUESTIONS = {
quality: score("How well does the answer satisfy the request?", [
"Does not answer the request.",
"Partly answers it, with a gap the reader would notice.",
"Fully answers the request.",
]),
grounded: noul("Every factual claim is supported by the files or tool results in the transcript."),
};
两条规则防止 agent 永远重试下去。它总共最多有两次尝试。 而且当 Jev 对自己的评分没信心时,harness 接受这个答案,而不是为又一次尝试付钱。
安全与日志
Jev 给你一个概率,而概率可能是错的。所以任何普通代码能确定地检查的东西,都应该用代码检查。 在这个 harness 里,每一个文件工具都拒绝项目文件夹之外的任何路径,不管 Jev 说什么。 把 Jev 留给那些代码做不了的判断。
闸门只看工具调用本身,也就是工具的名字和它的输入。这有助于应对提示词注入——那种把文本藏在文件或网页里、诱骗模型做坏事的攻击。闸门看不到那个诱骗,但它仍然看得到诱骗所导出的那个有害调用。
记录每一个决定,连同它背后的数字。 日志解释了某个东西为什么被拦住,也给出了设置阈值的真实数字。这个 harness 把每个决定写一行到一个叫 decisions.jsonl 的文件里。 闸门会看到 agent 试图做的每一件事,所以日志会隐藏邮箱地址和密钥,并缩短过长的输入。
为什么构建你自己的 harness
现成的 agent 用它们自己内置的规则来做这些决定。自定义 harness 把它们放进你的代码里。 你挑选模型、设定阈值、决定什么时候让人介入,并且能在日志里读到每次调用到底是为什么发生的。
Jev 是让这一切变得可行的东西。每个决定只花几百毫秒、成本是极小的零头,所以你可以在任何需要检查的地方加检查,而不只是在你能负担得起一次完整模型调用的地方。
这个教程里的三个部分只是起点。 为你自己领域定制的 harness,可以问 Jev 任何在那里重要的问题。
其他用途
同样这三个部分在编码 agent 之外也能用:
-
代码审查机器人:给每个建议的改动打分,只把值得读的那些给人看
-
记录清理:合并之前先问两条记录是不是在描述同一件事,把拿不准的成对记录留给人
-
文档流水线:给每个抽取出来的页面打分,只重跑低分的那些
-
审批队列:只有落在「问人」区间的调用才会到达人工审核者
在每一种情况下,语言模型做开放性的工作,Jev 回答它周围那些小问题。
附:我实测的数据(直连 TypeSafe,jev-1.13.0)
教程里那些分数是作者给的示例值。我用直连 TypeSafe 的方式把三个组件都跑了一遍,实测结果如下。
闸门组件:
delete_path(删除笔记) noul 0.85 → 阻断 1421ms
read_file(读取笔记) noul 0.01 → 放行 482ms
write_file(新建文件) noul 0.42 → 转人工 390ms
list_dir(列目录) noul 0.01 → 放行 404ms
有一处和教程描述不同:教程说新建文件大约得 0.70 分、会被闸门拦住(他把它当成闸门太钝的证据)。我实测是 0.42,落在「转人工」区间。0.42 其实更合理——新建文件确实不像覆盖已有文件那么危险。双阈值在这里表现得比教程描述的单阈值更好。
路由器组件:
「读出第一行」(简单) tier fast, 置信 1.00, 复杂度 0.0 → fast
「为什么 8 月里程低于 7 月」(难) tier fast, 置信 0.10, 复杂度 1.2 → powerful
第二条很说明问题:Jev 选了 fast,但它自己只有 0.10 的置信度,策略因此升到了强模型。如果只看它给的选项、不看置信度,这里就用错模型了。「置信度是第二根轴」这句话,在这里变成了一个具体的分支。
验证器组件(同一个问题,给两个答案):
有依据的答案(12.4 公里,transcript 里有) quality 2.00 grounded 0.94
编造的答案(18.7 公里 + 编的天气) quality 1.08 grounded 0.02
grounded 这个判断的区分度很大:0.94 对 0.02。
延迟:12 次热调用,最短 422ms,中位数 465ms,最长 1602ms。教程说的 200-400ms 偏乐观,中位数更接近 465ms。
另外教程设的 2 秒超时偏紧:冷启动第一次调用实测 1854ms,很贴近 2000ms 的上限,首次调用会偶发超时。建议放宽到 5 秒。
链接
推文:https://x.com/omarsar0/status/2102762406204076532
完整教程与 playground:https://academy.dair.ai/resources/jev-decisions-in-a-pi-sdk-harness
启发它的文章(Sydney Runkle,LangChain 博客):https://blog.langchain.com/building-a-harness-with-jev/
#Jev #Pi #Agent工程
沿着这个主题继续读
从一篇文章,找到相关的教程、经验和阅读路径。



