Skip to content

feat: record LLM input as deltas on trace spans - #7605

Merged
davidzhao merged 11 commits into
mainfrom
dz/otel-input-truncation
Oct 5, 2026
Merged

davidzhao merged 11 commits into
mainfrom
dz/otel-input-truncation

Conversation

@davidzhao

@davidzhao davidzhao commented Oct 3, 2026 •

Copy link
Copy Markdown
Member

Summary

At each turn, the LLM spans record all of the conversation and all of the system instructions. Thus, the size of the spans increases at each turn of a call.

This change adds an option. When you set the option, the spans record only the input that changed. The model always receives all of the input. The option changes only the telemetry.

await session.start(agent, record={"input_delta": True})

The option is off by default. When the option is off, the spans do not change.

Design

The module telemetry/input_delta.py contains the full description. This is a summary.

Terms

  • Record: the list of entries that a span records. On an llm_node span, the record is lk.pii.chat_ctx, with one entry for each chat item. On an llm_request span, the record is gen_ai.input.messages, with one entry for each GenAI message.
  • Instructions: the instructions message of the agent. On an llm_request span, the instructions are in gen_ai.system_instructions, not in the record. All other system messages are entries of the record, at their position. This change to gen_ai.system_instructions also applies when the option is off.
  • Commit: a generation is committed when its speech is scheduled. A discarded preemptive generation is not committed.
  • Parent: the span of the last committed generation that has the same span name.

Procedure for each span

  1. The tracker calculates a key for each item of the record: the item ID and a fingerprint of the content.
  2. It compares the keys with the keys of the parent, in order. It finds the longest shared prefix.
  3. The span records only the entries after this prefix.
  4. lk.input.dropped_from_base gives the number of parent entries after this prefix.
  5. On an llm_request span, if the instructions are the same as in the parent, the span does not record gen_ai.system_instructions.
  6. If the span shares no entries and no instructions with the parent, the span records all of the input.
  7. When the generation is committed, the keys of the span replace the keys of the parent.

Rebuild

  1. Make the full record of the parent (lk.input.base_span_id). Use this procedure again if the parent also has a parent.
  2. Remove the last lk.input.dropped_from_base entries.
  3. Add the entries of this span.

If an llm_request span with a parent has no gen_ai.system_instructions, use the instructions of the parent.

Rules

  • The comparison does not use the role of an item. A system message that stays at its position is shared. The framework adds the expressive guide again at the end of each reply. Thus, the old guide is dropped, and the new guide is recorded.
  • An edited, removed or moved item stops the shared prefix at that item. The span records the entries from that item. It does not record all of the input.
  • In gen_ai.input.messages, consecutive tool calls of an assistant turn are one message, also when a skipped item (for example, a config update) is between them. The prefix never stops inside such a message.
  • On an llm_node span, the instructions are the first entry of lk.pii.chat_ctx. Thus, a change of the instructions causes a full record of lk.pii.chat_ctx.
  • The fingerprint does not include media data. It uses the image ID and the audio transcript.
  • Each AgentActivity has its own tracker. After a handoff, the first span records all of the input.
  • The span of the FallbackAdapter wrapper does not record the input. Only the span of the provider records the input.

Example (expressive mode)

Generation Input sent to the model Record of llm_node lk.input.dropped_from_base
Turn 1 [I, u1, G] [I, u1, G] (full) none
Turn 2 [I, u1, a1, u2, G] [a1, u2, G] 1
Turn 3, tool call [I, …, u2, a2, u3, G] [a2, u3, G] 1
Turn 3, tool reply [I, …, u3, G, fc, fo] [fc, fo] 0

Attributes on a delta span: lk.input.delta, lk.input.base_span_id (and a span link to the parent), lk.input.dropped_from_base.

Relation to #7539

This change replaces the delta mode of #7539. It keeps these items from #7539: content fingerprints, no input on delegating spans, and the test scenarios.

This change is different from #7539 in these items:

  • Each delta span identifies its parent span and the number of dropped entries. Thus, you can make the full input again.
  • Only committed generations change the baseline. Preemptive generations and other LLM calls do not change the baseline.
  • The span does not record the system instructions again when they do not change.
  • The option applies to one session. It does not apply to all of the process.
  • lk.pii.chat_ctx stays on the span as a delta. feat: add opt-in telemetry content export controls #7539 removes it.

Refactor

Each ChatItem type now has the base class _ChatItemBase. This base class has no fields. Each type declares its essential fields one time in _essential_fields(). ChatContext.is_equivalent compares these fields, and _fingerprint() hashes them. The operation of is_equivalent does not change. New tests in tests/test_chat_ctx.py examine it.

Tests

tests/test_input_delta.py contains unit tests for the tracker. It also contains session tests: two turns with a tool step, the option off, and a fallback.

davidzhao and others added 2 commits October 3, 2026 12:42
Add RecordingOptions.input_truncation (off by default). When on, a span's
gen_ai.input.messages / lk.pii.chat_ctx hold only the conversation items
appended since the last committed generation, and gen_ai.system_instructions
is left out while unchanged. Truncated spans point at their base span via
lk.input.* attributes and a span link.

Only scheduled generations move the baseline, so discarded preemptive
generations never consume the delta. Items are fingerprinted when committed;
any edit, removal or reorder falls back to the full context.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…fingerprints

Every ChatItem type now derives from a fieldless _ChatItemBase and declares
its _essential_fields() once. ChatContext.is_equivalent compares them and
_fingerprint() hashes them, replacing the per-type branches in is_equivalent
and the type switch in telemetry. is_equivalent's behavior is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@davidzhao davidzhao changed the title feat: record LLM input incrementally on trace spans feat: record LLM input as deltas on trace spans Oct 3, 2026
davidzhao and others added 2 commits October 3, 2026 13:13
Nothing is truncated: the model always receives the full input, and spans
record only what changed since the last committed generation, pointing at
the span that holds the rest. Rename the option to
RecordingOptions.input_delta, the marker attribute to lk.input.delta, and
the helpers to InputDeltaTracker / InputDeltaScope / input_delta() /
InputDelta, with docstrings describing what each does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@davidzhao
davidzhao marked this pull request as ready for review October 3, 2026 21:12
@davidzhao
davidzhao requested a review from a team as a code owner October 3, 2026 21:12
devin-ai-integration[bot]

This comment was marked as resolved.

lk.pii.chat_ctx keeps system messages among the conversation, so a delta
that replaced the instructions could not be rebuilt by following the base
chain: the base's old system message and the new one both survived. The
llm_node span now records either the full context or only appended items
(instructions unchanged). gen_ai.* keeps its message delta, since its
instructions are a separate attribute.

Add a test that rebuilds each span's input from the lk.input.* chain and
compares it with what was sent, for both attributes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread livekit-agents/livekit/agents/telemetry/gen_ai.py Outdated
Comment thread livekit-agents/livekit/agents/telemetry/gen_ai.py Outdated
Comment thread livekit-agents/livekit/agents/telemetry/gen_ai.py Outdated
davidzhao and others added 2 commits October 4, 2026 09:29
_PendingBaseline now stores a _MessagesBaseline and an _InstructionsBaseline,
so committing a generation just stores them. delta() also builds the item
keys once and compares the baseline against their prefix, instead of
hashing the conversation twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Only the system/developer messages a chat context starts with are the
agent's instructions. A later one (generate_reply(instructions=...), the
expressive guide) exists for a single turn and is sent in place by
chat-completion providers, so it is now part of gen_ai.input.messages (role
"system") instead of gen_ai.system_instructions.

Input deltas leave per-turn system messages out of the comparison and record
them where the turn has them. Previously the guide appended to every turn
stayed where the base span recorded it, so the rebuilt conversation put the
new turns after it. To rebuild: the base's conversation without its system
messages, followed by this span's.

InputDelta keeps instructions and conversation apart, since a delta may
start with a per-turn system message. The site and its record shape travel
together as InputDeltaSite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread livekit-agents/livekit/agents/telemetry/gen_ai.py Outdated
span_context = span.get_span_context()

messages = _MessagesBaseline(
item_keys=_item_keys([i for i in conversation if not _is_system_message(i)]),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: some provider format formats the non-first system message as an user message, so the otel data do not reflect the actual request. Example: a generate_reply(instructions=...) greeting, [I, greet]:

telemetry gen_ai.system_instructions: ['be brief', 'greet the user']
anthropic system:                     ['be brief']
anthropic messages:                   [user: '<instructions>greet the user</instructions>']

davidzhao and others added 2 commits October 4, 2026 21:35
A span now continues the input recorded for the last committed generation,
its parent: it records what follows the longest prefix they share, and
leaves out instructions identical to the parent's. There is no separate
instructions pointer and no assumption about which system messages last a
single turn, so a persistent system message (added through
update_chat_ctx), a per-turn RAG message and an edit mid-history all stay
deltas. lk.pii.chat_ctx counts chat items (lk.input.base_items) and
gen_ai.input.messages counts messages (lk.input.base_messages), never
cutting a tool call from its message.

gen_ai.system_instructions now holds only the agent's instructions message
(or, without one, the system message the context starts with); every other
system message stays in gen_ai.input.messages in place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tracker gets its own module, whose docstring describes the design in
ASD-STE100: terms, the procedure for each span, the rebuild rule, the rules
for system messages, edits and tool calls, and an expressive-mode example
(checked by a test).

Replace lk.input.base_items / lk.input.base_messages with one
lk.input.dropped_from_base: the number of entries removed from the end of
the parent's record before this span's are appended. Each span name has one
record (lk.pii.chat_ctx on llm_node, gen_ai.input.messages on llm_request),
so one count is enough, and it stays small however long the history is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
devin-ai-integration[bot]

This comment was marked as resolved.

…eltas

A tool call merges into the assistant message before it even when a
skipped item (such as the AgentConfigUpdate from update_tools) sits between
them. _shared_prefix stopped backtracking at the skipped item, so the delta
rendered the call as a separate assistant message. A cut is now valid only
when the next item that lands in a message starts a new one, on both sides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@chenghao-mou chenghao-mou left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm, two nits. Tested in DD:

Image

Comment thread livekit-agents/livekit/agents/telemetry/input_delta.py
Comment thread livekit-agents/livekit/agents/llm/chat_context.py
The placeholder output got a new random id every turn, so while a tool kept
running across turns, each input delta stopped at it and re-recorded
everything after it. The placeholder call reused the real call's id, and
its only difference (the flag in extra) is not part of the fingerprint, so
once the tool finished, the rebuilt lk.pii.chat_ctx kept the flagged copy.

The pair now uses "<call id>_running" and "<call id>_running_output": the
same on every turn while the tool runs, and distinct from the real call and
output that replace it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@davidzhao
davidzhao merged commit 8a677a6 into main Oct 5, 2026
23 checks passed
@davidzhao
davidzhao deleted the dz/otel-input-truncation branch October 5, 2026 15:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants