Signals
让模型针对每个请求回答一个问题,并将答案作为列写回:是谁导致了故障、是否值得通知值班人员、客户是否得到了想要的结果。

日志会告诉你请求返回了 502。signal 会告诉你原因是 Stripe,重试本来可以成功,而且不值得叫醒任何人。日志会告诉你结账请求返回了 200。signal 会告诉你客户离开时并没有创建订单。

@evlog/signals 让决策模型针对每个事件回答一个问题,并将带有置信度的答案作为列写回事件。你可以像查询其他字段一样查询它。

你会得到什么

signal 是一个英文问题,附加到适用的事件上。当事件匹配时,模型读取事件,为每个选项给出概率,并将答案作为类型化列写回事件:

a signal·idle
wide eventPOST · /api/checkout
{
path: "/api/checkout"
status: 502
error: "ECONNRESET api.stripe.com"
durationMs: 2310
signals.fault:
{ value: "upstream", confidence: 0.93 }
}
the signalpick one
ask: "Who is responsible for this failure?"
client
0.03
app
0.04
upstream
0.93
eventquestionprobabilitiescolumn

问题本身就是完整配置。无需调整 prompt,也无需编写解析器:是/否问题给出布尔值,选项列表给出其中一个选项,有序评分表给出级别和位置。每列都带有模型置信度,因此 WHERE signals.fault.value = 'upstream' AND signals.fault.confidence > 0.9 是查询,而不是猜测。

它能提供三种事件字段规则无法提供的结果:

  • 每个错误都有原因。 Stripe 502 的结果是 fault=upstream,空值解引用的结果是 fault=app。状态图上看起来相同的两个 5xx,在 GROUP BY 中会成为两行。一周后它会变成一个数字:你的错误预算中有多少确实来自你的系统。
  • 200 也可能是失败。 结账请求返回了 200 却没有创建订单,webhook 得到确认后被丢弃,agent 在重复调用同一个工具。这些事件没有任何字段说明出错;带有 keep 的 signal 会让它们通过采样,下一节会展示这一点。
  • 成本低到你不会察觉。 决策模型在一次短调用中回答该事件需要的所有 signal:演示运行中的 15 个请求只产生 14 次调用、1,569 个输入 token 和 0.00007 美元。成本介绍了在你的流量下会是什么结果。

添加第一个 signal

为 evlog 应用添加 signals

安装

Terminal
pnpm add @evlog/signals ai

默认模型是通过 Vercel AI Gateway 使用的 typesafe-ai/jev。请设置 AI_GATEWAY_API_KEY;在 Vercel 上无需设置,系统会自动使用 OIDC。

编写问题

signal 包含名称、决定适用事件的谓词和问题。下面这个 signal 会标记每个失败:

server/signals.ts
import { defineSignal } from '@evlog/signals'

export const fault = defineSignal({
  name: 'fault',
  when: e => (e.status ?? 0) >= 400,
  ask: 'Who is responsible for this failure?',
  choice: {
    client: 'Bad input, expired session, client mistake',
    app: 'A bug or misconfiguration in our own code',
    upstream: 'A third-party dependency failed',
  },
})

注册

createSignals 返回一个 evlog 插件。Nuxt 和 Nitro 直接暴露这两个钩子;其他集成则通过 plugins 数组接收它:

server/plugins/evlog-signals.tsserver/plugins/evlog-signals.tslib/evlog.tssrc/hooks.server.tsserver/plugins/evlog-signals.tsapp/root.tsxsrc/index.tssrc/index.tssrc/index.tssrc/index.tssrc/app.module.tsserver/orpc.tssrc/worker.ts
import { createSignals } from '@evlog/signals'
import { fault } from '../signals'

export default defineNitroPlugin((nitroApp) => {
  const plugin = createSignals({ signals: [fault] })
  nitroApp.hooks.hook('evlog:emit:keep', plugin.keep)
  nitroApp.hooks.hook('evlog:enrich', plugin.enrich)
})
import { definePlugin } from 'nitro'
import { createSignals } from '@evlog/signals'
import { fault } from '../signals'

export default definePlugin((nitroApp) => {
  const plugin = createSignals({ signals: [fault] })
  nitroApp.hooks.hook('evlog:emit:keep', plugin.keep)
  nitroApp.hooks.hook('evlog:enrich', plugin.enrich)
})
import { initLogger } from 'evlog'
import { createSignals } from '@evlog/signals'
import { fault } from './signals'

initLogger({ plugins: [createSignals({ signals: [fault] })] })
import { createEvlogHooks } from 'evlog/sveltekit'
import { createSignals } from '@evlog/signals'
import { fault } from './signals'

export const { handle, handleError } = createEvlogHooks({ plugins: [createSignals({ signals: [fault] })] })
import { definePlugin } from 'nitro'
import { createSignals } from '@evlog/signals'
import { fault } from '../signals'

export default definePlugin((nitroApp) => {
  const plugin = createSignals({ signals: [fault] })
  nitroApp.hooks.hook('evlog:emit:keep', plugin.keep)
  nitroApp.hooks.hook('evlog:enrich', plugin.enrich)
})
import { evlog } from 'evlog/react-router'
import { createSignals } from '@evlog/signals'
import { fault } from './signals'

export const middleware: Route.MiddlewareFunction[] = [
  evlog({ plugins: [createSignals({ signals: [fault] })] }),
]
import { evlog } from 'evlog/hono'
import { createSignals } from '@evlog/signals'
import { fault } from './signals'

app.use(evlog({ plugins: [createSignals({ signals: [fault] })] }))
import { evlog } from 'evlog/express'
import { createSignals } from '@evlog/signals'
import { fault } from './signals'

app.use(evlog({ plugins: [createSignals({ signals: [fault] })] }))
import { evlog } from 'evlog/fastify'
import { createSignals } from '@evlog/signals'
import { fault } from './signals'

await app.register(evlog, { plugins: [createSignals({ signals: [fault] })] })
import { evlog } from 'evlog/elysia'
import { createSignals } from '@evlog/signals'
import { fault } from './signals'

app.use(evlog({ plugins: [createSignals({ signals: [fault] })] }))
import { EvlogModule } from 'evlog/nestjs'
import { createSignals } from '@evlog/signals'
import { fault } from './signals'

EvlogModule.forRoot({ plugins: [createSignals({ signals: [fault] })] })
import { withEvlog } from 'evlog/orpc'
import { createSignals } from '@evlog/signals'
import { fault } from './signals'

const handler = withEvlog(new RPCHandler(router), { plugins: [createSignals({ signals: [fault] })] })
import { withEvlog } from 'evlog/workers'
import { createSignals } from '@evlog/signals'
import { fault } from './signals'

export default withEvlog(handler, { plugins: [createSignals({ signals: [fault] })] })
import { initLogger } from 'evlog'
import { createSignals } from '@evlog/signals'
import { fault } from './signals'

initLogger({ plugins: [createSignals({ signals: [fault] })] })

读取列

接下来到达 drain 的 500 个事件会携带答案:

Wide Event
{
  "method": "POST",
  "path": "/api/checkout",
  "status": 502,
  "error": { "name": "FetchError", "message": "request to https://api.stripe.com/... failed, reason: read ECONNRESET" },
  "signals": {
    "fault": { "value": "upstream", "confidence": 0.93 }
  }
}

value 是答案,confidence 是答案的概率(是/否始终有该字段;当模型返回分布时,choice 和 score 也有该字段)。列会在控制台行之前写入,因此它们会出现在开发终端、Vercel 等平台日志以及每个 drain 中。

保留采样丢弃的内容

为 signal 添加 keep 后,值为 true 的判定会强制事件通过采样。假设生产规则收到六个请求,info 的保留率为 0%:采样保留两个错误,其中四个 200 响应有三个因为 signal 的判定而返回:

signals·idle
request · samplingoutcome
POST/api/checkout200dropped
kept
POST/api/checkout502kept
kept
GET/api/orders500kept
kept
POST/webhooks/stripe200dropped
kept
POST/api/agent/turn200dropped
kept
GET/api/products200dropped
dropped
kept by sampling0 / 6
kept with signals0 / 6
model calls0

这就是拯救静默结账的 signal:

server/signals.ts
export const silentFailure = defineSignal({
  name: 'silent-failure',
  when: e => e.status === 200 && e.path === '/api/checkout',
  ask: 'Returned 200, but the customer did not get what they came for',
  keep: v => v.value && v.confidence > 0.8,
})

keep 只负责提升保留级别。它不会丢弃采样规则本来会保留的事件,被提升的事件会在列上携带 kept: true,因此统计数字仍然准确。

下一步

可询问的内容

是/否、单选或评分表。了解每种形状会产生什么列,以及如何编写模型确实能够回答的问题。

成本

事件成本、首先生效的速率限制、keep 路径的延迟,以及哪些内容会离开进程。

参考

defineSignal 和 createSignals 的全部选项、列格式、stats() 以及测试辅助工具,集中在一个页面中。

配方

大多数应用第一天就需要的三个 signal,以及另外九个可直接参考的 signal。