没人给 Agent 编过预算:跨部门的 AI 用量到底怎么记帐

·阅读约10分钟·Evergreen Tools Team

10 月 30 日,Cognition 发布了面向 Devin 的企业治理方案,与银行、零售、电信的几家大客户共同打磨。把公告读细一点会发现它的范围很窄也很具体:解决的是大规模使用 AI Agent 时的资金归属与监督问题,对象是那种「几千名工程师散在不同业务单元里、消费的是按用量计价的 Agent」的组织。各家报道反复用的词是「谁付钱」问题,这个词不错,因为它点到了真正的摩擦点。用 Agent 的人是一个拿着工单的开发者;对那个数字负责的人,是隔着两个层级、在另一个成本中心里的经理。中间那层工具,过去谁也不管这件事。

团队在长桌旁一起工作

用 Agent 的人和付钱的人,几乎从来不是同一个。

一、公告说了什么,以及它没说什么

已披露的事实是这些:产品面向的是「Agent 在多个部门、数千个工程座位上被消耗」的大型组织;与银行、零售、电信客户共同开发,这几个行业监管要求重、内部结构复杂;对外描述是「让 AI 投入的资金归属透明、能追踪谁在付钱、并对天然分散的用量做预算控制」。公告里没有的,是任何机制层面的东西:没有计量单位、没有策略模型、没有 API、也没有一句话说明预算用完了会怎样。我不打算替它猜。我能做的是描述这类产品必然具备的设计,因为不管你买还是自己造,底层问题是一样的。

# Attribution has to exist at the call site, or it is a guess. Wrap the agent's
# entry point once, and let the run id travel through every model and tool call.
import contextvars, json, time, uuid
from dataclasses import dataclass, asdict, field

RUN = contextvars.ContextVar("agent_run")


@dataclass
class RunContext:
    run_id: str
    cost_centre: str          # the thing finance reports on
    project: str
    ticket: str
    model: str = ""
    input_tokens: int = 0
    cached_tokens: int = 0
    output_tokens: int = 0
    tool_calls: int = 0
    started: float = field(default_factory=time.time)
    outcome: str = "open"

    def event(self, kind: str, **fields):
        # One line per billable step. Append to your warehouse, not the vendor's.
        print(json.dumps({"run": asdict(self), "kind": kind, **fields}))


def start_run(cost_centre: str, project: str, ticket: str) -> RunContext:
    ctx = RunContext(str(uuid.uuid4()), cost_centre, project, ticket)
    RUN.set(ctx)
    return ctx


def current() -> RunContext:
    ctx = RUN.get(None)
    if ctx is None:
        # Untagged runs are the leading cause of unexplained spend. Fail loudly
        # in staging and tag them as "unattributed" in production.
        ctx = RunContext(str(uuid.uuid4()), "unattributed", "unknown", "unknown")
        RUN.set(ctx)
    return ctx

# The test that matters: start a run, let the agent call a tool, and assert that
# the tool's record carries the same run_id. If it does not, that work is
# billable and invisible, which is the worst combination.

二、按量计费把「按座位」的预算打碎了

建立在座位上的软件预算有一个财务部门默默依赖的属性:它事先就是已知的。买一百个 licence,数字就定了,偏差是零。Agent 的定价不是这样。Cognition 用 ACU 计量 Devin,也就是归一化的 Agent 算力单位;其他厂商按 token、按分钟、按任务。不管这个单位叫什么,花的钱随「委派出去多少工作」而增长,而这件事是由离问题最近的那个人、一张工单一张工单地决定的。乘上几千名工程师,你得到的就是一个没人预测过的数字,用一种没人做过预算的计价方式,并且归不到任何人头上。这不是采购的失职,这是把一件变动成本的工具,塞进一套为固定成本设计的流程里必然发生的事。

# The ledger. One row per run, event-sourced from the per-step events, closed
# daily. Small enough to read in a review, which is the point.
import json, sqlite3
from pathlib import Path

db = sqlite3.connect("agent_usage.db")
db.executescript(
    "CREATE TABLE IF NOT EXISTS runs ("
    "  run_id TEXT PRIMARY KEY,"
    "  cost_centre TEXT NOT NULL,"
    "  project TEXT NOT NULL,"
    "  ticket TEXT NOT NULL,"
    "  model TEXT,"
    "  day TEXT NOT NULL,"
    "  input_tokens INTEGER DEFAULT 0,"
    "  cached_tokens INTEGER DEFAULT 0,"
    "  output_tokens INTEGER DEFAULT 0,"
    "  tool_calls INTEGER DEFAULT 0,"
    "  outcome TEXT DEFAULT 'open'"
    ");"
    "CREATE INDEX IF NOT EXISTS runs_day ON runs(day, cost_centre);"
)

def ingest(path: str):
    with open(path, encoding="utf-8") as fh:
        for line in fh:
            ev = json.loads(line)
            r = ev["run"]
            db.execute(
              "INSERT INTO runs (run_id, cost_centre, project, ticket, model, day,"
              " input_tokens, cached_tokens, output_tokens, tool_calls, outcome)"
              " VALUES (?,?,?,?,?,?,?,?,?,?,?)"
              " ON CONFLICT(run_id) DO UPDATE SET"
              "   input_tokens = excluded.input_tokens,"
              "   cached_tokens = excluded.cached_tokens,"
              "   output_tokens = excluded.output_tokens,"
              "   tool_calls = excluded.tool_calls,"
              "   outcome = excluded.outcome",
              (r["run_id"], r["cost_centre"], r["project"], r["ticket"],
               r["model"], r.get("day", "1970-01-01"),
               r["input_tokens"], r["cached_tokens"], r["output_tokens"],
               r["tool_calls"], r["outcome"]))
    db.commit()

def monthly_statement(month: str, rate_in: float, rate_cached: float, rate_out: float):
    rows = db.execute("""
      SELECT cost_centre, SUM(input_tokens), SUM(cached_tokens),
             SUM(output_tokens), COUNT(*)
      FROM runs WHERE day LIKE ? GROUP BY cost_centre ORDER BY 2 DESC
    """, (month + "%",)).fetchall()
    for centre, tin, tcached, tout, runs in rows:
        billed = (tin - tcached) / 1e6 * rate_in + tcached / 1e6 * rate_cached \
                 + tout / 1e6 * rate_out
        print(f"{centre:<24} runs={runs:<6} ${billed:>10,.2f}")

# Every row here is a claim you may have to defend. Keep the raw events too, so
# the claim can be re-derived by somebody who does not trust your rollup.

三、归集是工程问题,而且从调用点就开始了

任何一套内部计费系统,最后都归结成一个问题:对这一份花费,你能不能说清它属于谁。对 Agent 来说,这意味着归集信息必须存在于发起调用的地方,而不是事后从那些根本没打算承载它的日志里反推出来。实际可用的最低标准,是一条运行记录:一个稳定的 id、一个成本中心、一个项目、一个工单号,并且贯穿这次运行所发的每一次模型调用和每一次工具调用——包括 Agent 自己决定额外发出的那些,而那恰恰是最让预算主人意外的部分。计量单位的选择也很重要。token 好测,难向经理解释;「等效人力小时」好解释,难诚实地测。选你能站得住的那个,但原始计数器无论如何都要存,这样以后还能把另一个推导出来。

# Reserve before the spend. The point is not to be strict, it is to make an
# over-budget state a product decision with a defined cheaper path.
import time
from dataclasses import dataclass


@dataclass
class Budget:
    cost_centre: str
    limit_usd: float
    spent_usd: float = 0.0
    reserved_usd: float = 0.0

    def available(self) -> float:
        return self.limit_usd - self.spent_usd - self.reserved_usd


BUDGETS = {"payments-platform": Budget("payments-platform", 4_000.0)}


def estimate(prompt_tokens: int, max_output_tokens: int, rate_in: float,
             rate_out: float, cached_ratio: float = 0.0) -> float:
    fresh = prompt_tokens * (1 - cached_ratio)
    return fresh / 1e6 * rate_in + max_output_tokens / 1e6 * rate_out


def admit(cost_centre: str, prompt_tokens: int, max_output_tokens: int):
    """Return the plan this run may use, or refuse with a defined fallback."""
    b = BUDGETS[cost_centre]
    want = estimate(prompt_tokens, max_output_tokens, 1.0, 4.0, cached_ratio=0.6)
    if want <= b.available():
        b.reserved_usd += want
        return {"tier": "standard", "reservation": want}

    # Degrade before denying: a cheaper model, a shorter completion, a later slot.
    cheap = estimate(prompt_tokens, 2_000, 0.15, 0.6, cached_ratio=0.6)
    if cheap <= b.available():
        b.reserved_usd += cheap
        return {"tier": "degraded", "reservation": cheap,
                "note": "budget low: cheaper model, 2k completion cap"}

    return {"tier": "queued", "reservation": 0.0,
            "note": "over budget: queued until period close or an approved override"}

# Two properties worth testing: concurrent admissions must not each receive the
# full remaining amount, and the refusal path must be a documented behaviour
# rather than whatever the exception handler happened to do.

四、只会出报表的预算是账单,会拦人的预算才是控制

第一代 Agent 成本工具的通病,是它只是个看板。它会在月初三号告诉你:某个团队已经把整个季度的额度花掉了。到那个时候,选择早就做完了。真正有效的是在花钱之前、在请求路径上就执行:按这次运行的预估用量做预留,预留拿不到就拒绝或降级,而降级要做成产品决策而不是一个错误页——换便宜模型、缩小上下文、排队。预留还能顺手修掉一个很隐蔽的记帐问题:并发运行共享同一份剩余预算时,不能每一个都按全额放行。另外一定要留一条明确的例外通道,因为「发版周谁也没法override」这种治理工具,第二周就会被人关掉。

# The number a manager will ask for: cost per unit of work that actually
# landed. Compute it from the ledger joined to your delivery data.
import csv, sqlite3

db = sqlite3.connect("agent_usage.db")
db.execute("CREATE TEMP TABLE merged AS SELECT 1")  # placeholder for clarity

def outcomes(path: str) -> dict:
    # ticket -> (merged_prs, swallowed) from your VCS export
    out = {}
    with open(path, newline="", encoding="utf-8") as fh:
        for row in csv.DictReader(fh):
            out[row["ticket"]] = (int(row.get("merged_prs") or 0),
                                 row.get("swallowed", "no") == "yes")
    return out

def report(vcs_csv: str, rate_in=1.0, rate_out=4.0, rate_cached=0.1):
    deliv = outcomes(vcs_csv)
    rows = db.execute("""
      SELECT ticket, SUM(input_tokens), SUM(cached_tokens), SUM(output_tokens),
             COUNT(*)
      FROM runs GROUP BY ticket
    """).fetchall()

    for ticket, tin, tcached, tout, runs in rows:
        cost = ((tin - tcached) / 1e6 * rate_in
                + tcached / 1e6 * rate_cached
                + tout / 1e6 * rate_out)
        prs, swallowed = deliv.get(ticket, (0, False))
        if prs:
            print(f"{ticket:<18} ${cost:>8,.2f}  prs={prs}  ${cost/prs:>8,.2f}/pr")
        elif swallowed:
            # Spend with no artefact is not waste — it is the honest cost of
            # exploration, and the only way to keep that line visible is to log it.
            print(f"{ticket:<18} ${cost:>8,.2f}  abandoned (keep, in a separate line)")

# Two rules that keep this honest: never divide by zero-merges, and never
# average abandoned work into the cost per PR. Both flatter the number and
# both get caught the first time somebody reads the raw rows.

五、对账,是治理赢得信任或失去信任的地方

你自己的账本和厂商的发票一定对不上,问题只在于你是从自己的月结里发现的,还是从财务来问你「这个数字为什么动了」的时候发现的。把对账做成月度任务:按成本中心汇总你的用量事件,与发票条目做关联,按团队报出差异,并且阈值要设得有意义——在团队层面,三个百分点不是噪声,通常意味着计量有缺口。两个最常见的原因是:新的入口绕过了打标层,导致有些运行从来没被归集;以及厂商计费了重试、而你的封装把重试折叠成了一个事件。这两种都不稀奇,但除非有人专门把两份数据 join 一次,它们都不会自己冒出来。

#!/usr/bin/env python3
# Reconcile your ledger against the vendor invoice, monthly. Variance at team
# level is the signal; a single total that roughly matches is not.
import csv, sqlite3, sys
from collections import defaultdict

THRESHOLD = 0.03  # 3% at team level is not noise

db = sqlite3.connect("agent_usage.db")
ours = dict(db.execute("""
  SELECT cost_centre, SUM(input_tokens - cached_tokens) / 1e6 * 1.0
       + SUM(cached_tokens) / 1e6 * 0.1 + SUM(output_tokens) / 1e6 * 4.0
  FROM runs WHERE day LIKE ? GROUP BY cost_centre
""", (sys.argv[1] + "%",)).fetchall())

theirs = defaultdict(float)
with open(sys.argv[2], newline="", encoding="utf-8") as fh:
    for row in csv.DictReader(fh):
        theirs[row["cost_centre"]] += float(row["amount_usd"])

bad = 0
for centre in sorted(set(ours) | set(theirs)):
    a, b = ours.get(centre, 0.0), theirs.get(centre, 0.0)
    delta = (b - a) / a if a else float("inf")
    flag = "  CHECK" if abs(delta) > THRESHOLD else ""
    print(f"{centre:<24} ours={a:>9,.2f} invoice={b:>9,.2f} delta={delta:>7.1%}{flag}")
    bad += bool(flag)

raise SystemExit(1 if bad else 0)

# When a line is flagged, look at two things first: runs tagged 'unattributed'
# (a new entry point skipped the wrapper) and retry counts on your side versus
# billable attempts on theirs. Those two explain most first-pass variances.

六、买产品还是自己写那三十行,但要在第一张发票之前决定

如果厂商把归集、拦截、报表打成一个产品卖你,而且能对接你的身份系统和成本中心层级,那买它很合理。不该外包的是那份 schema,因为换厂商的时候,你留下的是它:运行 id、成本中心、项目、工单、模型、输入 token、缓存 token、输出 token、工具调用次数、耗时、以及这次工作的结果。把它存进你自己的数仓,而不是只存在厂商控制台里。这样到续约的时候,那是一个工程决策而不是一个迁移项目;而月末那个问题——这笔钱谁付的——有一个不依赖任何人家的看板能否打开的答案。

笔记本屏幕上的图表与数字

事后报表是一张账单;预算是在花钱之前就生效的控制。

放着手提电脑、笔记本和咖啡的桌面

你按什么单位计量,决定了月末你能维护什么结论。

📌 常见问题 FAQ

Cognition 在 10 月 30 日到底发布了什么?

一套面向 Devin 的企业治理方案,用来解决「Agent 在多个部门、数千个工程座位上被大量消耗」的情况下,投入资金如何归属与监督的问题。公告说明它是与银行、零售、电信客户共同开发的,提供透明的资金归属,并对天然分散的用量提供预算控制。公告没有公布计量模型、没有 API,也没有说明预算的强制执行机制。

为什么按座位做预算,对编码 Agent 不够用?

因为 Agent 的花费随「委派出去的工作量」增长,而不是随人头增长。座位预算是固定且事先已知的;按量计价则随交办给 Agent 的工作量浮动,而这个量是由离问题最近的工程师一张工单一张工单决定的。几千个各自独立的小决策,最终合成一个年度计划里没人预测过的数字。

最低限度可用的计量 schema 长什么样?

一条运行记录:稳定 id,加上成本中心、项目、工单、模型、输入 token、缓存 token、输出 token、工具调用次数、耗时和结果——在调用点写,存进你自己的数仓。如果工具调用上没有运行 id,那么 Agent 自己决定做的那些工作就是「可计费但不可见」,这是最糟的组合。

预算该拦人还是只出报表?

该拦,并且要定义好降级路径。看板只会在月初三号告诉你某团队已经把季度额度花完,那是一张收据,不是控制。在请求路径上按预估用量做预留,预留失败时降级——更便宜的模型、更短的输出上限、排进队列——而不是在一个开发者干到一半时抛个错误。

我的账本和发票多久会不一致一次?

经常,而且这些不一致是有信息量的。最常见的原因有两个:新的入口绕过了打标封装,导致部分运行没被归集;以及你这边把重试折叠成一个事件,而厂商按每次尝试计费。要按月、按成本中心对账,并设一个小的差异阈值,因为总额对得上,可能只是两个方向相反的大误差互相抵消。