Get 2,500 events tracked for freeSign up now

All tutorials
Syed Raza12 min

Cost per agent in a LangChain incident-brief pipeline

A planner, parallel analysts, a critic and a writer in LangChain 1.x on Claude, then per-agent cost and a blocking budget on the role that multiplies.

Framework
LangChain 1.x
Provider
anthropic
Python
3.11+
Capsera
pip install capsera

This builds an incident-brief pipeline in LangChain 1.x against Claude: a planner turns a raw report into a bounded list of questions, analysts answer them in parallel from a fixed evidence bundle, a critic finds what is still missing, and a writer produces the brief. One run is a handful of Haiku calls plus a single Sonnet call, and the analyst's share of that is decided by how many questions the planner wrote — a model's output, not a constant in your file. The last third of the page adds Capsera: one init() call to capture every request, four decorator lines to say which role made it, and a budget that stops the analyst before it calls.

What you will build

brief.py, a single-file pipeline with four roles and a supervisor:

  • planner — one call. Turns the report into at most six questions.
  • analyst — one call per question, run in parallel. Answers only from a fixed evidence bundle, or says UNKNOWN.
  • critic — one call. Returns the gaps worth a second pass, at most two.
  • writer — one call, on a stronger model. Produces the brief.

The supervisor is a plain Python function, not a graph. That is deliberate: the subject here is where multi-agent cost comes from, and a readable control flow makes the role boundaries visible. If you want the same instrumentation on a LangGraph app, the node-level equivalent is langgraph_node.

Each role is its own function. That matters later — it is the seam the instrumentation attaches to — and it is the better shape regardless, because a supervisor that inlines four model calls has nothing you can name, budget, or swap a model on.

The cost problem in this tutorial only exists because one request becomes many calls. If your app makes a single call per request, there is nothing to group and nothing to amplify.

Written against LangChain 1.x with langchain-anthropic, calling Anthropic's API directly rather than through a gateway. Models: claude-haiku-4-5 for the three cheap roles, claude-sonnet-4-6 for the writer.

Prerequisites

Python 3.11 or newer.

pip install "langchain>=1.0,<2" langchain-anthropic

The major is pinned so this page stays true as LangChain moves. langchain-anthropic is left unpinned on purpose: install the one that matches your langchain major.

You need an Anthropic API key. ChatAnthropic reads ANTHROPIC_API_KEY from the environment, so export it and no code has to touch it:

export ANTHROPIC_API_KEY=...

Step 1 — Fix the evidence the analysts may read

Start with the corpus, not the agents. A fixed evidence bundle is what makes the run's cost predictable in the first place: an analyst that can search has an unbounded number of calls in it, while an analyst handed a bundle has exactly one.

"""brief.py — the evidence an analyst is allowed to read."""

EVIDENCE = {
    "deploy-log": (
        "13:58 UTC  api-gateway 8f21c3 built from main\n"
        "14:02 UTC  8f21c3 to canary, 10% of traffic\n"
        "14:09 UTC  8f21c3 autopromoted to 100%\n"
        "14:31 UTC  rollback to 7d90aa started\n"
        "14:36 UTC  rollback complete"
    ),
    "error-rates": (
        "checkout POST /orders 5xx: 0.3% at 14:00, 4% at 14:11, 11% at 14:18, "
        "0.4% at 14:38\n"
        "payments POST /charge 5xx: flat at 0.2% all afternoon\n"
        "api-gateway p99: 180ms at 14:00, 2.4s at 14:15, 190ms at 14:40"
    ),
    "runbook": (
        "api-gateway holds 40 connections per instance to payments. Pool "
        "exhaustion surfaces as 5xx on checkout, never on payments, because the "
        "gateway fails the request before forwarding it. Release 8f21c3 raised "
        "the pool acquire timeout from 250ms to 5s."
    ),
    "oncall-chat": (
        "14:14 payments oncall: dashboards green, charge volume normal\n"
        "14:22 checkout oncall: every failure I sample is a gateway 503\n"
        "14:29 platform oncall: rolling 8f21c3 back, no sev declared"
    ),
}


def evidence_text() -> str:
    return "\n\n".join(f"[{name}]\n{body}" for name, body in EVIDENCE.items())


if __name__ == "__main__":
    print(f"{len(EVIDENCE)} sources, {len(evidence_text())} characters")

Note what evidence_text() produces: one string, identical on every analyst call in a round. That single fact drives most of what the cost report will say at the end of this page.

Step 2 — The planner, with the fan-out bound in code

The planner decides how wide the pipeline goes. So the cap lives in Python, not in the prompt — a model asked for "at most six" will occasionally write eight, and the slice is what makes that harmless.

# fragment
import re

from langchain_anthropic import ChatAnthropic
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate

FAST_MODEL = "claude-haiku-4-5"
MAX_QUESTIONS = 6

_BULLET = re.compile(r"^\s*(?:[-*•]|\d+[.)])\s*")


def _lines(text: str, limit: int) -> list[str]:
    cleaned = (_BULLET.sub("", line).strip() for line in text.splitlines())
    return [line for line in cleaned if line][:limit]


PLANNER_PROMPT = ChatPromptTemplate.from_messages(
    [
        (
            "system",
            "You triage production incidents. Given a report, write the "
            "questions an analyst must answer to explain what happened. One "
            "question per line. No numbering, no preamble. At most "
            "{max_questions} questions.",
        ),
        ("human", "{report}"),
    ]
)

planner_llm = ChatAnthropic(model=FAST_MODEL, max_tokens=512, temperature=0)
plan_chain = PLANNER_PROMPT | planner_llm | StrOutputParser()


def plan_questions(report: str) -> list[str]:
    raw = plan_chain.invoke({"report": report, "max_questions": MAX_QUESTIONS})
    return _lines(raw, MAX_QUESTIONS)

Lines rather than JSON. A one-question-per-line contract degrades into a shorter list when the model ignores you; a malformed JSON object degrades into an exception, and a retry on a parse failure is a call you pay for twice.

Each role gets its own ChatAnthropic instance even where the model string repeats. Three of these are the same model today, and the point of separating them is that changing one role's model later is a one-line edit instead of a refactor.

Step 3 — The analysts, in parallel

Six sequential calls that do not depend on each other is six round trips of latency for no reason. batch() runs them concurrently, and max_concurrency bounds how many are in flight.

# fragment
ANALYST_PROMPT = ChatPromptTemplate.from_messages(
    [
        (
            "system",
            "You answer one question about a production incident using only the "
            "evidence below. Quote the line you relied on. If the evidence does "
            "not answer the question, reply with the single word UNKNOWN.\n\n"
            "Evidence:\n{evidence}",
        ),
        ("human", "{question}"),
    ]
)

analyst_llm = ChatAnthropic(model=FAST_MODEL, max_tokens=512, temperature=0)
analyst_chain = ANALYST_PROMPT | analyst_llm | StrOutputParser()


def investigate(questions: list[str]) -> list[dict[str, str]]:
    evidence = evidence_text()
    payloads = [{"question": q, "evidence": evidence} for q in questions]
    answers = analyst_chain.batch(payloads, config={"max_concurrency": 4})
    return [
        {"question": q, "answer": a.strip()}
        for q, a in zip(questions, answers, strict=True)
        if a.strip() != "UNKNOWN"
    ]

UNKNOWN answers are dropped rather than passed on. An analyst that pads a non-answer to look useful poisons the critic, which then asks for a second pass, which costs another round of calls — a quality failure that shows up as a cost failure two steps downstream.

zip(..., strict=True) because pairing questions to answers by position is only safe if the lengths agree, and a silent truncation here would attach the wrong answer to the wrong question.

Step 4 — The critic and the writer

The critic exists to bound the second pass. Asking "what is missing" without a limit invites a list of everything imaginable, and every item on that list is an analyst call.

# fragment
MAX_GAPS = 2
WRITER_MODEL = "claude-sonnet-4-6"

CRITIC_PROMPT = ChatPromptTemplate.from_messages(
    [
        (
            "system",
            "You review an incident investigation for gaps. List only questions "
            "that are still unanswered and that the evidence could plausibly "
            "answer. One per line, at most {max_gaps}. If there are none, reply "
            "with the single word NONE.",
        ),
        ("human", "Report:\n{report}\n\nFindings so far:\n{findings}"),
    ]
)

WRITER_PROMPT = ChatPromptTemplate.from_messages(
    [
        (
            "system",
            "You write incident briefs for engineers. Four short sections: what "
            "happened, impact, cause, what to do next. Use only the findings "
            "given. Do not speculate. Under 250 words.",
        ),
        ("human", "Report:\n{report}\n\nFindings:\n{findings}"),
    ]
)

critic_llm = ChatAnthropic(model=FAST_MODEL, max_tokens=256, temperature=0)
writer_llm = ChatAnthropic(model=WRITER_MODEL, max_tokens=1024, temperature=0)

critic_chain = CRITIC_PROMPT | critic_llm | StrOutputParser()
writer_chain = WRITER_PROMPT | writer_llm | StrOutputParser()


def format_findings(findings: list[dict[str, str]]) -> str:
    return "\n\n".join(f"Q: {f['question']}\nA: {f['answer']}" for f in findings)


def review(report: str, findings: list[dict[str, str]]) -> list[str]:
    raw = critic_chain.invoke(
        {
            "report": report,
            "findings": format_findings(findings),
            "max_gaps": MAX_GAPS,
        }
    )
    if raw.strip().upper().startswith("NONE"):
        return []
    return _lines(raw, MAX_GAPS)


def write_brief(report: str, findings: list[dict[str, str]]) -> str:
    return writer_chain.invoke(
        {"report": report, "findings": format_findings(findings)}
    )

The writer is the only role on the stronger model, because it is the only one whose output a person reads. The other three produce input for another model, and that is the judgement worth revisiting later with real numbers rather than guessing now.

Step 5 — The supervisor

One function, and every branch in the pipeline is visible in it.

# fragment
REPORT = (
    "Checkout started failing this afternoon and recovered on its own. "
    "The payments team says they were healthy the whole time. "
    "Nobody has written anything down."
)


def brief_incident(report: str) -> str:
    questions = plan_questions(report)
    if not questions:
        raise RuntimeError("the planner returned no questions")

    findings = investigate(questions)
    gaps = review(report, findings)
    if gaps:
        findings = findings + investigate(gaps)
    return write_brief(report, findings)

Count the calls: one planner, up to six analysts, one critic, up to two more analysts, one writer. Nine or ten calls for one brief, and only three of those counts are fixed by your code.

The whole file

"""brief.py — an incident brief from a planner, analysts, a critic and a writer."""
import re

from langchain_anthropic import ChatAnthropic
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate

FAST_MODEL = "claude-haiku-4-5"
WRITER_MODEL = "claude-sonnet-4-6"
MAX_QUESTIONS = 6
MAX_GAPS = 2

EVIDENCE = {
    "deploy-log": (
        "13:58 UTC  api-gateway 8f21c3 built from main\n"
        "14:02 UTC  8f21c3 to canary, 10% of traffic\n"
        "14:09 UTC  8f21c3 autopromoted to 100%\n"
        "14:31 UTC  rollback to 7d90aa started\n"
        "14:36 UTC  rollback complete"
    ),
    "error-rates": (
        "checkout POST /orders 5xx: 0.3% at 14:00, 4% at 14:11, 11% at 14:18, "
        "0.4% at 14:38\n"
        "payments POST /charge 5xx: flat at 0.2% all afternoon\n"
        "api-gateway p99: 180ms at 14:00, 2.4s at 14:15, 190ms at 14:40"
    ),
    "runbook": (
        "api-gateway holds 40 connections per instance to payments. Pool "
        "exhaustion surfaces as 5xx on checkout, never on payments, because the "
        "gateway fails the request before forwarding it. Release 8f21c3 raised "
        "the pool acquire timeout from 250ms to 5s."
    ),
    "oncall-chat": (
        "14:14 payments oncall: dashboards green, charge volume normal\n"
        "14:22 checkout oncall: every failure I sample is a gateway 503\n"
        "14:29 platform oncall: rolling 8f21c3 back, no sev declared"
    ),
}

REPORT = (
    "Checkout started failing this afternoon and recovered on its own. "
    "The payments team says they were healthy the whole time. "
    "Nobody has written anything down."
)

_BULLET = re.compile(r"^\s*(?:[-*•]|\d+[.)])\s*")


def evidence_text() -> str:
    return "\n\n".join(f"[{name}]\n{body}" for name, body in EVIDENCE.items())


def _lines(text: str, limit: int) -> list[str]:
    cleaned = (_BULLET.sub("", line).strip() for line in text.splitlines())
    return [line for line in cleaned if line][:limit]


PLANNER_PROMPT = ChatPromptTemplate.from_messages(
    [
        (
            "system",
            "You triage production incidents. Given a report, write the "
            "questions an analyst must answer to explain what happened. One "
            "question per line. No numbering, no preamble. At most "
            "{max_questions} questions.",
        ),
        ("human", "{report}"),
    ]
)

ANALYST_PROMPT = ChatPromptTemplate.from_messages(
    [
        (
            "system",
            "You answer one question about a production incident using only the "
            "evidence below. Quote the line you relied on. If the evidence does "
            "not answer the question, reply with the single word UNKNOWN.\n\n"
            "Evidence:\n{evidence}",
        ),
        ("human", "{question}"),
    ]
)

CRITIC_PROMPT = ChatPromptTemplate.from_messages(
    [
        (
            "system",
            "You review an incident investigation for gaps. List only questions "
            "that are still unanswered and that the evidence could plausibly "
            "answer. One per line, at most {max_gaps}. If there are none, reply "
            "with the single word NONE.",
        ),
        ("human", "Report:\n{report}\n\nFindings so far:\n{findings}"),
    ]
)

WRITER_PROMPT = ChatPromptTemplate.from_messages(
    [
        (
            "system",
            "You write incident briefs for engineers. Four short sections: what "
            "happened, impact, cause, what to do next. Use only the findings "
            "given. Do not speculate. Under 250 words.",
        ),
        ("human", "Report:\n{report}\n\nFindings:\n{findings}"),
    ]
)

planner_llm = ChatAnthropic(model=FAST_MODEL, max_tokens=512, temperature=0)
analyst_llm = ChatAnthropic(model=FAST_MODEL, max_tokens=512, temperature=0)
critic_llm = ChatAnthropic(model=FAST_MODEL, max_tokens=256, temperature=0)
writer_llm = ChatAnthropic(model=WRITER_MODEL, max_tokens=1024, temperature=0)

plan_chain = PLANNER_PROMPT | planner_llm | StrOutputParser()
analyst_chain = ANALYST_PROMPT | analyst_llm | StrOutputParser()
critic_chain = CRITIC_PROMPT | critic_llm | StrOutputParser()
writer_chain = WRITER_PROMPT | writer_llm | StrOutputParser()


def format_findings(findings: list[dict[str, str]]) -> str:
    return "\n\n".join(f"Q: {f['question']}\nA: {f['answer']}" for f in findings)


def plan_questions(report: str) -> list[str]:
    raw = plan_chain.invoke({"report": report, "max_questions": MAX_QUESTIONS})
    return _lines(raw, MAX_QUESTIONS)


def investigate(questions: list[str]) -> list[dict[str, str]]:
    evidence = evidence_text()
    payloads = [{"question": q, "evidence": evidence} for q in questions]
    answers = analyst_chain.batch(payloads, config={"max_concurrency": 4})
    return [
        {"question": q, "answer": a.strip()}
        for q, a in zip(questions, answers, strict=True)
        if a.strip() != "UNKNOWN"
    ]


def review(report: str, findings: list[dict[str, str]]) -> list[str]:
    raw = critic_chain.invoke(
        {
            "report": report,
            "findings": format_findings(findings),
            "max_gaps": MAX_GAPS,
        }
    )
    if raw.strip().upper().startswith("NONE"):
        return []
    return _lines(raw, MAX_GAPS)


def write_brief(report: str, findings: list[dict[str, str]]) -> str:
    return writer_chain.invoke(
        {"report": report, "findings": format_findings(findings)}
    )


def brief_incident(report: str) -> str:
    questions = plan_questions(report)
    if not questions:
        raise RuntimeError("the planner returned no questions")

    findings = investigate(questions)
    gaps = review(report, findings)
    if gaps:
        findings = findings + investigate(gaps)
    return write_brief(report, findings)


if __name__ == "__main__":
    print(brief_incident(REPORT))
python brief.py

The output is a four-section brief assembled only from the evidence bundle, which is why one runbook line about the connection pool ends up carrying more of the explanation than the whole chat log. The application is finished. Nothing below changes what it does.

Seeing what it cost

What you do not have is any idea which of the four roles spent the money. The provider's invoice has one line for the API key, and the nine or ten calls behind it are indistinguishable — same key, same account, and all but one from the same model.

pip install capsera

At the top of brief.py, above everything else:

# fragment
import os

import capsera

capsera.init(api_key=os.environ["CAPSERA_API_KEY"], endpoint="https://api.capsera.ai")

That is the capture step, and it is already done. capsera.init() patches the provider clients that ChatAnthropic constructs underneath, so from that call on every request the pipeline makes is recorded: the planner's, each analyst's inside batch(), the critic's, the writer's. The prompts are untouched, the chains are untouched, batch() is untouched, and no traffic changed route — the SDK runs inside your process rather than in front of it, so there is no proxy, no base_url to swap, and nothing new in the request path.

One addition for a script: a background thread ships events on an interval, so a process that exits immediately can exit before the queue drains.

# fragment
if __name__ == "__main__":
    print(brief_incident(REPORT))
    capsera.shutdown()

capsera.shutdown() flushes pending events and stops the worker. A long-running service does not need it; a script, a cron job or a notebook cell does, and omitting it is the usual reason a first run reports nothing.

Run it now and you have a total, a per-model split and token counts per call. You also have every one of those events attributed to unknown, because nothing has said which role made it. unknown is a real row, not a dropped one — an unattributed cost is still a cost — but it is one row where you wanted four.

Name the roles

The calls are already captured. The decorator only says who:

# fragment
@capsera.agent("planner", team="incident-response", task_type="triage")
def plan_questions(report: str) -> list[str]:
    ...  # body unchanged


@capsera.agent("analyst", team="incident-response", task_type="evidence-qa")
def investigate(questions: list[str]) -> list[dict[str, str]]:
    ...  # body unchanged


@capsera.agent("critic", team="incident-response", task_type="review")
def review(report: str, findings: list[dict[str, str]]) -> list[str]:
    ...  # body unchanged


@capsera.agent("writer", team="incident-response", task_type="drafting")
def write_brief(report: str, findings: list[dict[str, str]]) -> str:
    ...  # body unchanged

Four added lines, and that is the entire diff. No function body changed, no call site changed, no model construction changed, no argument threaded through the supervisor. Every LLM call made while a decorated function is on the stack is attributed to that agent, including calls made by the functions it calls and by the framework it invokes.

Two details worth knowing before you trust the numbers:

The fan-out keeps its identity. investigate does its work in analyst_chain.batch(...), which dispatches to worker threads rather than calling on the thread you were on. The analyst scope is still what gets recorded. Capsera holds the attribution stack in a ContextVar and LangChain's batch() dispatches through a context-copying executor, so the scope the caller was in is the scope the worker sees — verified empirically on LangChain 1.3 with capsera 0.3.0. batch() also produces one event per underlying call rather than one per batch, so six questions are six analyst events (coverage table).

The credit there belongs to LangChain, and it is worth knowing where the guarantee stops. A ContextVar does not cross a thread boundary on its own — it crosses when the code starting the thread copies the context, which batch() does and a hand-rolled pool does not. Fan the analysts out yourself with a bare ThreadPoolExecutor and the same decorator records unknown instead of analyst, with no error to tell you. Measured on the same versions:

# fragment
# Loses the scope: workers start with a fresh context.
with ThreadPoolExecutor(max_workers=4) as pool:
    answers = list(pool.map(answer_one, questions))

# Keeps it: hand each worker the caller's context.
ctx = contextvars.copy_context()
with ThreadPoolExecutor(max_workers=4) as pool:
    answers = list(pool.map(lambda q: ctx.run(answer_one, q), questions))

Async needs nothing extra — a ContextVar is snapshotted per task, so roles awaited concurrently under asyncio.gather keep their own attribution.

Leave the supervisor undecorated. brief_incident makes no LLM call of its own; every call inside it happens while one of the four roles is on the stack, and the innermost scope is the one recorded. Decorating it would add a row that can never have spend on it. For a pipeline-level total, use the team field — which is what team="incident-response" on all four decorators is for.

Put a budget on the role that multiplies

Three of the four roles make exactly one call per run, and your code decides that. The analyst's call count is decided by the planner's output: up to six in the first round, up to two more after the critic. The number that sets the run's cost is not written anywhere in brief.py, which is multi-agent amplification in one sentence, and it is why the cap belongs on the analyst rather than on the pipeline.

Create the budget in the dashboard with scope agent and the agent set to analyst — the same string as the decorator. That string is the whole join between the two halves of this page: a budget can only act on a boundary the events carry, and spend attributed to unknown cannot be governed by an agent budget. Pick the amount from a month of your own analyst spend rather than a figure from a tutorial, and set the action to block.

Enforcement is on by default (enable_budget_enforcement=True in init()), and the check runs before the provider call. When the analyst is over its budget, BudgetExceededError is raised inside your process and the request never leaves it, so no tokens are spent. The cost of that guarantee is one round trip to Capsera per call.

Then decide what a blocked analyst means for the brief. batch() raises on the first failure and abandons the rest of the inputs unless you tell it otherwise, so this is a decision you make rather than one you inherit:

# fragment
@capsera.agent("analyst", team="incident-response", task_type="evidence-qa")
def investigate(questions: list[str]) -> list[dict[str, str]]:
    evidence = evidence_text()
    payloads = [{"question": q, "evidence": evidence} for q in questions]
    answers = analyst_chain.batch(
        payloads,
        config={"max_concurrency": 4},
        return_exceptions=True,
    )

    findings: list[dict[str, str]] = []
    for question, answer in zip(questions, answers, strict=True):
        if isinstance(answer, capsera.BudgetExceededError):
            raise answer  # the cap is real: stop, do not ship a partial brief
        if isinstance(answer, BaseException):
            continue  # one flaky call: the other answers still stand
        if answer.strip() != "UNKNOWN":
            findings.append({"question": question, "answer": answer.strip()})
    return findings

return_exceptions=True asks batch() to hand failures back as items in the result list instead of raising, which is what separates the two cases: a single call that failed for its own reasons is survivable, and a budget block is not something to paper over with five answers out of six. An incident brief with a silent hole in it is worse than no brief.

The re-raise then has one place to land:

# fragment
import capsera


def run_once(report: str) -> str | None:
    """Returns None when a budget stopped the run."""
    try:
        return brief_incident(report)
    except capsera.BudgetExceededError as exc:
        print(f"analyst budget reached, no brief produced: {exc}")
        return None

BudgetExceededError is the only exception the SDK raises deliberately; everything else fails open. Do not catch it and retry immediately — the budget will still be exceeded. Degrading (a template, a cheaper model called directly), queueing until the period rolls over, and failing are all defensible; for internal batch work, failing usually is.

Two limits to know before you rely on this as a hard ceiling:

The parallel round can overshoot. The check reads budget state computed from delivered events, and events are delivered in batches every 500 ms, so a burst of concurrent calls can each pass a check taken before any of them was recorded. With max_concurrency at 4, up to four analyst calls can clear the same recorded total. Set the budget slightly below a figure you cannot exceed, or lower max_concurrency.

It fails open, on purpose. If the pre-call check does not complete within budget_check_timeout (1 second by default), the call is allowed. A network problem between your service and Capsera should not halt production traffic. Of the three actions, block and margin downgrade act at runtime; throttle records the decision but does not delay the call. The full behaviour is in budgets and enforcement.

What this run actually cost

No dollar figures on this page: this pipeline has not been run against a real key, and a number invented here would not be yours anyway — it depends on your evidence bundle, how many questions your planner writes, and today's prices. What is worth predicting is the shape of the result.

Four agent rows — planner, analyst, critic, writer — under one team, incident-response, with a cost per run for the brief as a whole.

The analyst row is the only one that moves between two runs on the same report, because its call count came from a model rather than from your code. Watch its call count next to its cost: a jump in cost per run with flat cost per call is a planner writing more questions, and that is a different fix from a prompt getting longer.

Which row is largest is not predictable in advance, and that is the point of measuring it. The writer makes one call on the more expensive model; the analyst makes six to eight on the cheaper one. Where the crossover falls depends on the fan-out width and on the size of your evidence bundle, and it decides whether the next thing to change is the writer's model or MAX_QUESTIONS.

Input tokens per analyst call are dominated by the evidence bundle, which is byte-identical on every call in a round. Cache read share will read zero, because nothing in this code sets an Anthropic cache_control breakpoint — the prefix repeats and gets billed as fresh input every time. That is a lever this code leaves on the table, and it is invisible on an invoice: prompt caching that never engaged.

Each event also carries the file and line that made the call, which points at brief.py rather than into langchain_core, so a row you did not expect traces back to code rather than to a model name.

Questions this page answers

How do I get per-agent cost for a LangChain multi-agent pipeline?
Call capsera.init() once, which patches the provider clients ChatAnthropic and ChatOpenAI construct underneath, so every call is already recorded. Then put @capsera.agent("<role>") on each role function. The decorator adds who made the call; it does not change whether the call is captured.
Does @capsera.agent attribution survive LangChain's batch()?
Yes. Capsera keeps the attribution stack in a ContextVar and LangChain's batch() dispatches through a context-copying executor, so the scope the caller was in is the scope the worker thread sees. Verified empirically on LangChain 1.3 with capsera 0.3.0. batch() also produces one event per underlying call rather than one per batch, so a fan-out of six questions is six analyst events.
Can I put a budget on one agent in a LangChain app?
Yes, if that agent's spend is attributed. A budget with scope agent matches on the agent identifier the events carry, which is the string in the decorator. Spend attributed to unknown cannot be governed by an agent budget, so attribution comes first. With enforcement on, the check runs before the provider call and a blocking budget raises BudgetExceededError, so no tokens are spent.
What happens to a LangChain batch when a budget blocks one call?
By default batch() raises on the first failure and abandons the remaining inputs, so one blocked analyst call ends the whole round. Pass return_exceptions=True to get failures back as items in the result list, then decide per item whether to degrade on partial findings or stop the run.

Give every agent an identity, a budget, and hard limits.

One line of code. Anthropic, OpenAI, and Google Gemini.

See pricing