1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

複数の書き手がいるDB — written_by と status でLLMの推測を隔離する

1
Posted at

先週, 別件のブログ用に project_assets の再利用率を集計しようとしたら, 明らかに実在しない「PoC汎用モジュール」がトップ3に入っていた。DBを覗いてみると, 作った覚えのない relation が3件挿さっている。犯人は誰でもなく, 最近走らせ始めたブログ用エージェントだった。

1人で受託をやっている, と前回まで書いていた。ただ, 書き手は1人でも「DBに書き込む主体」は増えていく。ブログ生成, X投稿の下書き, 営業リサーチ。今のところ3種類のエージェントが同じ Supabase に触っている。今回は, その状況で「LLMの推測」と「確定した事実」をどう分けたか, の話。

複数の筆記が中央のトレイに集まる机の俯瞰

結論: 2つの列を足すだけ

先に着地点を書く。project_assets テーブルに written_bystatus を足した。それだけで, 集計クエリの汚染は止まった。

-- 前回 (第2回) で作った project_assets を拡張する
ALTER TABLE project_assets
  ADD COLUMN written_by TEXT NOT NULL DEFAULT 'human',
  ADD COLUMN status TEXT NOT NULL DEFAULT 'confirmed',
  ADD COLUMN confidence REAL,
  ADD COLUMN proposed_at TIMESTAMPTZ,
  ADD COLUMN confirmed_at TIMESTAMPTZ,
  ADD CONSTRAINT status_check
    CHECK (status IN ('proposed', 'confirmed', 'rejected'));
-- 集計はほぼ confirmed しか見ないので, partial index で軽くする
CREATE INDEX assets_confirmed_idx
ON project_assets (asset_id)
WHERE status = 'confirmed';

written_by'human', 'blog_agent', 'x_agent', 'sales_agent' のいずれか。status'proposed''confirmed'。人が入れたものはデフォルトで confirmed, エージェントが入れたものは常に proposed から始まる。ここで一段抽象化を挟まないのは意図的で, プロダクトが小さいうちは列を足す方が壊れにくい。

初手では別テーブル (project_assets_proposed) に分けようとした。テーブルが物理的に分かれていた方が汚染しないはずだ, と思ったからだ。結果的にはやめた。エージェントが提案した relation を人が承認するとき「別テーブルから移す」INSERT + DELETE のトランザクションを毎回書くのが億劫で, 実際にはレビュー画面を開かなくなる。同じテーブルに status 列を足す方が, レビューUI側で UPDATE ... SET status = 'confirmed' 一発で済む。承認コストが下がると, 承認が回る。

集計は confirmed のみを見る

前回書いた再利用率のクエリはこう変わった。

-- 再利用率 = 2件以上の source_project に登場する asset の割合
SELECT
COUNT() FILTER (WHERE reuse_count >= 2)::float
/ NULLIF(COUNT(), 0) AS reuse_rate
FROM (
SELECT asset_id, COUNT(DISTINCT source_project) AS reuse_count
FROM project_assets
WHERE status = 'confirmed'          -- ★ 足したのはこの1行だけ
GROUP BY asset_id
) t;

足したのは WHERE status = 'confirmed' の1行。ただ, これがないとブログ用エージェントが「たぶんこの案件, 前の認証モジュールを使い回してそう」と推測して書き込んだ relation が全部混ざる。数字は勝手に上がって, 意思決定が壊れる。partial index を張っておくと proposed が増えても集計は遅くならない。

エージェント同士は呼ばない, DBだけを読む

2026年のこのあたりの潮流として, LangGraph のように「共有 State を最初に定義してからエージェントを組む」型のフレームワークが主流になってきた。よくできていて, 精密な制御が要る場面では強い。ただ, 1人スタジオの規模だと, フレームワークを1本据えて全エージェントをそこに押し込むのは過剰な気がしていた。

今の構成では, フレームワークを挟まず, エージェント同士は互いを呼ばない。全員, DBだけを読む。

// blog_agent が新しい記事を書く前に「文脈」を取りに行く
const { data: reusable } = await supabase
.from('project_assets')
.select('asset_id, source_project, notes')
.eq('status', 'confirmed')         // 推測は読まない
.in('source_project', relatedProjectIds)
.order('confirmed_at', { ascending: false })
.limit(20);
// この結果をそのまま prompt に混ぜる
const prompt = buildPrompt({
topic: input.topic,
reusableAssets: reusable ?? [],
});

これだけで, ブログエージェントは「うちが実際に使い回している資産の一覧」を毎回見て記事を書く。X用のエージェントも同じテーブルを別の切り口で読む。エージェント間の contract は「テーブルのスキーマ」だけだ。片方が壊れても, もう片方は動き続ける。

正直, これはフレームワーク信仰から見ればナイーブな構成だと思う。エラー時のリトライも, 並行制御も, 全部 Postgres 側の制約 (unique 制約, transaction, advisory lock) に任せている。ただ, 「共有 State = DB」と割り切ると, 新しいエージェントを1匹足すコストが劇的に下がる。既存の Supabase クライアントを渡して SELECT ... WHERE status = 'confirmed' と書かせるだけで, そいつはその時点までの確定した文脈を持って動き始める。

エージェントが提案 → 人が承認, の書き方

提案側と承認側は, こう分けた。

// 1. blog_agent が「この案件でこの asset を使ったのでは」と推測
await supabase.from('project_assets').insert({
source_project: 'client-x-2026Q3',
asset_id: 'auth-module-v2',
written_by: 'blog_agent',
status: 'proposed',
confidence: 0.62,
proposed_at: new Date().toISOString(),
notes: 'README に「既存の認証を流用」と記載あり',
});

レビュー画面では status = 'proposed' の行だけ拾って表示する。承認は次の1行。

UPDATE project_assets
SET status = 'confirmed',
written_by = 'human',            -- 承認した瞬間から「人が保証」
confirmed_at = NOW()
WHERE id = $1;

ここで written_by'human' に上書きしているのは意図的だ。誰が最初に書いたかは proposed_at と別の audit テーブルに残しているが, 「今この relation を保証している主体」は承認した瞬間から人になる。この列は「起源」ではなく「今の保証者」を表す, と決めた。設計時に一度混乱したので念のため書いておく。

小さいNと, 自分の見積もりバイアス

ここまで書いておいて何だが, 手元の project_assets テーブルは行数がまだ2桁台だ。統計というより方向性を見ている, というのが正直なところ。「再利用率が上がった」と胸を張って言えるNには, たぶんまだ半年はかかる。

もう1つ, 前回の記事で「関連付けに毎回30秒」と書いたが, あれは自己申告の見積もりで, 実測ではない。ストップウォッチを持って何回か測ってみると, 集中しているときは10秒, 別作業から思い出して切り替えるコストが乗ると2分, と分散した。第1回で「オントロジーを作らないオントロジー」と書いたときの前提の一部が, ここで少し崩れた。設計に着手する前に平均を測っておけばよかった, と後から思う話でもある。

まとめ

written_bystatus の2列だけで, LLMの推測は集計から隔離できる。エージェント同士は互いを呼ばず, DBだけを読み書きする。新しいエージェントを増やすときは, テーブルへの権限を渡して WHERE status = 'confirmed' と書かせれば, そいつはその時点までの確定した文脈を持てる。フレームワークを1本据えるより, スキーマ1枚で握るほうが, この規模には合っていた。

これで S7 の3本目, 「1人スタジオの軽量オントロジー」の連載はひとまず区切り。この上に LLM 側の推論を積む話 — proposed の自動生成をどこまで任せるか, confidence の較正をどう回すか — は, また別の連載でいずれ書く予定。

筆者は 5years+ で韓国・日本向けの AI 実装をやっている。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?