项目文件夹

文件
Simon Willison 05c55737ff
Test / test (macos-latest, 3.10) (push) Has been cancelled
Test / test (macos-latest, 3.11) (push) Has been cancelled
Test / test (macos-latest, 3.12) (push) Has been cancelled
Test / test (macos-latest, 3.13) (push) Has been cancelled
Test / test (macos-latest, 3.14) (push) Has been cancelled
Test / test (ubuntu-latest, 3.10) (push) Has been cancelled
Test / test (ubuntu-latest, 3.11) (push) Has been cancelled
Test / test (ubuntu-latest, 3.12) (push) Has been cancelled
Test / test (ubuntu-latest, 3.13) (push) Has been cancelled
Test / test (ubuntu-latest, 3.14) (push) Has been cancelled
Test / test (windows-latest, 3.10) (push) Has been cancelled
Test / test (windows-latest, 3.11) (push) Has been cancelled
Test / test (windows-latest, 3.12) (push) Has been cancelled
Test / test (windows-latest, 3.13) (push) Has been cancelled
Test / test (windows-latest, 3.14) (push) Has been cancelled
Useful SQL queries for trying out the DAG
2026-04-15 15:08:50 -07:00

315 行
9.3 KiB
Markdown

# DAG SQL queries
Reconstructing what happened from the DAG tables. Each query is
written against the schema from `plans/dag-schema.md`:
- `messages(id, parent_id, content_hash, role, provider_metadata_json, created_at)`
immutable, parent-linked nodes. The chain root points at a
self-referencing sentinel row with `id = 'root'`.
- `message_parts(id, message_id, order, part_type, content, content_json, tool_call_id, server_executed)`
the content of each message, one row per part in order.
- `calls(id, conversation_id, head_input_message_id, head_output_message_id, model, resolved_model, started_at, duration_ms, input_tokens, output_tokens, token_details_json, error)`
one row per LLM call. `head_input_message_id` is the last user-side
message the model saw; `head_output_message_id` is the last
assistant-side message it produced. Walking `parent_id` from the
output head back to (but not past) the input head gives the call's
new output; walking from the input head back to `'root'` gives the
full prior context.
- `conversations(id, name, model, head_message_id)`
`head_message_id` advances each saved turn.
All examples assume SQLite and use the recursive CTE pattern SQLite
supports natively. Run from any SQLite client pointed at the DB
(default `~/Library/Application Support/io.datasette.llm/dag.db` on
macOS in this branch).
## 1. Walk a conversation from its head
Given a `conversations.id`, reconstruct the full message sequence:
```sql
WITH RECURSIVE chain(id, parent_id, role, depth) AS (
SELECT
m.id,
m.parent_id,
m.role,
0
FROM
messages m
JOIN conversations c ON c.head_message_id = m.id
WHERE
c.id = :conversation_id
UNION ALL
SELECT
m.id,
m.parent_id,
m.role,
chain.depth + 1
FROM
messages m
JOIN chain ON chain.parent_id = m.id
WHERE
m.id != 'root'
)
SELECT
(
SELECT
MAX(depth)
FROM
chain
) - chain.depth AS turn,
chain.role,
mp.part_type,
CASE
mp.part_type
WHEN 'text' THEN mp.content
WHEN 'reasoning' THEN '[reasoning] ' || mp.content
WHEN 'tool_call' THEN '→ ' || json_extract(mp.content_json, '$.name') || '(' || COALESCE(
json_extract(mp.content_json, '$.arguments'),
'{}'
) || ')'
WHEN 'tool_result' THEN '← ' || substr(mp.content, 1, 200)
WHEN 'attachment' THEN '[attachment: ' || COALESCE(json_extract(mp.content_json, '$.type'), '?') || ']'
ELSE mp.part_type
END AS text
FROM
chain
LEFT JOIN message_parts mp ON mp.message_id = chain.id
ORDER BY
turn,
mp."order";
```
`depth DESC` reverses the walk so the oldest message comes first.
Drop the `m.id != 'root'` clause if you want to see the sentinel.
## 2. Show a conversation with the actual text
Same walk, joined to `message_parts` to surface content. One row
per part:
```sql
WITH RECURSIVE chain(id, parent_id, role, depth) AS (
SELECT m.id, m.parent_id, m.role, 0
FROM messages m
JOIN conversations c ON c.head_message_id = m.id
WHERE c.id = :conversation_id
UNION ALL
SELECT m.id, m.parent_id, m.role, chain.depth + 1
FROM messages m
JOIN chain ON chain.parent_id = m.id
WHERE m.id != 'root'
)
SELECT
chain.depth,
chain.role,
mp.part_type,
mp."order",
COALESCE(mp.content, mp.content_json) AS preview
FROM chain
LEFT JOIN message_parts mp ON mp.message_id = chain.id
ORDER BY chain.depth DESC, mp."order";
```
Each message can have multiple parts (a `tool_call` part followed by
a `text` part in the same assistant turn, for instance) — `ORDER BY
depth DESC, "order"` renders them in natural reading order.
## 3. The messages produced by a specific call
A call's new output is the slice of the chain from just after its
`head_input_message_id` through its `head_output_message_id`. This
walks from the output head backwards, stopping *before* the input
head:
```sql
WITH RECURSIVE output_chain(id, parent_id, role, depth) AS (
SELECT m.id, m.parent_id, m.role, 0
FROM messages m
JOIN calls c ON c.head_output_message_id = m.id
WHERE c.id = :call_id
UNION ALL
SELECT m.id, m.parent_id, m.role, output_chain.depth + 1
FROM messages m, output_chain, calls c
WHERE c.id = :call_id
AND output_chain.parent_id = m.id
AND output_chain.parent_id != c.head_input_message_id
)
SELECT depth, role, id FROM output_chain ORDER BY depth DESC;
```
If `head_output_message_id = head_input_message_id` the call produced
no new messages (error / refusal) — the query returns a single row
which you can filter out application-side.
## 4. List calls for a conversation with token usage
Token columns live only on `calls`. Conversations still show as rows
in the `conversations` table:
```sql
SELECT
c.id,
c.started_at,
c.model,
c.duration_ms,
c.input_tokens,
c.output_tokens,
c.input_tokens + c.output_tokens AS total_tokens,
c.error
FROM calls c
WHERE c.conversation_id = :conversation_id
ORDER BY c.started_at;
```
## 5. Find every call that used a given tool
The model requesting a tool shows up as a `message_parts` row with
`part_type = 'tool_call'`. Join to `calls` via the output chain —
here the simplest path is "did this call's output head, or any of
its ancestors up to the input head, contain a tool_call part with
this name?".
A cheaper approximation that works when tool calls are almost always
on the output head directly:
```sql
SELECT DISTINCT c.id, c.started_at, c.model
FROM calls c
JOIN message_parts mp ON mp.message_id = c.head_output_message_id
WHERE mp.part_type = 'tool_call'
AND json_extract(mp.content_json, '$.name') = :tool_name
ORDER BY c.started_at;
```
For the exhaustive version (tool call is anywhere in the output
chain), wrap query 3 as a subquery.
## 6. Find a conversation's branching points (forks)
A message has multiple children when two different turns continued
from it:
```sql
SELECT parent_id, COUNT(*) AS child_count
FROM messages
WHERE id != 'root'
GROUP BY parent_id
HAVING COUNT(*) > 1;
```
For each branching point, find the forked conversations:
```sql
SELECT c.id, c.name, c.head_message_id
FROM conversations c
WHERE EXISTS (
SELECT 1 FROM messages m
WHERE m.id = c.head_message_id
OR m.parent_id = c.head_message_id
);
```
To find conversations that share a common prefix with a known
conversation, walk the known chain and look for other conversations
whose `head_message_id` appears anywhere in it.
## 7. Content-address lookup (has this message been seen?)
Every message is identified by `(parent_id, content_hash)`. To ask
"does a chain rooted at system-prompt-X followed by user-message-Y
already exist?", chain lookups by hash:
```sql
-- 1) Find a root message with content_hash = :h_system
SELECT id FROM messages
WHERE parent_id = 'root' AND content_hash = :h_system;
-- 2) Given that id, find its child with content_hash = :h_user
SELECT id FROM messages
WHERE parent_id = :system_id AND content_hash = :h_user;
```
Chain dedup and stateless-API continuation are this query in a loop
— see `MessageStore.find_longest_existing_prefix` in
`llm/storage.py`.
## 8. Storage savings from shared prefixes
Count how many calls reference each input-head message. A value > 1
means two calls shared the same input chain (a retry, a regeneration,
or a fork converging back):
```sql
SELECT
head_input_message_id,
COUNT(*) AS call_count
FROM calls
GROUP BY head_input_message_id
HAVING COUNT(*) > 1
ORDER BY call_count DESC;
```
To see the saving in absolute terms — messages actually stored vs.
messages naïvely required (sum of chain lengths across all calls):
```sql
WITH RECURSIVE chain_lengths(call_id, cur, steps) AS (
SELECT c.id, c.head_output_message_id, 1
FROM calls c
UNION ALL
SELECT chain_lengths.call_id, m.parent_id, chain_lengths.steps + 1
FROM messages m, chain_lengths
WHERE m.id = chain_lengths.cur
AND m.id != 'root'
)
SELECT
(SELECT COUNT(*) FROM messages WHERE id != 'root') AS messages_stored,
(SELECT SUM(steps) FROM (
SELECT call_id, MAX(steps) AS steps FROM chain_lengths GROUP BY call_id
)) AS messages_naively_required;
```
## 9. Tool-call / tool-result round-trip for a call
Tool plumbing lives in `message_parts` on `tool_call_id`. Pair them:
```sql
SELECT
call_part.tool_call_id,
json_extract(call_part.content_json, '$.name') AS tool_name,
json_extract(call_part.content_json, '$.arguments') AS arguments,
result_part.content AS result_text
FROM message_parts call_part
LEFT JOIN message_parts result_part
ON result_part.tool_call_id = call_part.tool_call_id
AND result_part.part_type = 'tool_result'
WHERE call_part.part_type = 'tool_call';
```
`LEFT JOIN` so you see calls that never got a result (model hung up,
user bailed, tool errored in a way that wasn't logged as a part).
## 10. Raw chain dump for debugging
When something looks wrong, this prints every message in the DB with
its parent, role, and the first part's content preview:
```sql
SELECT
m.id,
m.parent_id,
m.role,
substr(m.content_hash, 1, 8) AS hash,
mp.part_type,
substr(COALESCE(mp.content, mp.content_json), 1, 60) AS preview
FROM messages m
LEFT JOIN message_parts mp ON mp.message_id = m.id AND mp."order" = 0
WHERE m.id != 'root'
ORDER BY m.created_at, m.id;
```
Ordering by `created_at` groups messages in insert order, which is
usually the order they happened — though dedup hits don't re-stamp,
so a resent prefix keeps its original `created_at`.