対象読者と動作環境
- Ollama で小型のオープンソースモデルを動かしていて、指示を無視した出力やフォーマット崩れに悩んでいる方
- Chain-of-Thought・Few-Shot・ReAct・Self-Consistencyという用語は知っているが、実装レベルでどう組み込むかのサンプルが欲しい方
- 動作確認環境: macOS / Node.js v24.14.0(
package.jsonのenginesは>=24.0.0)/ TypeScript ^5.8.3 /tsx^4.19.2
検証には筆者が作った将棋モチーフのローカルLLM専用マルチエージェントCLI「Jin」(GitHub)の src/agents/techniques.ts を使います。Jin 全体の紹介は末尾のリンクを参照してください。この記事では techniques.ts の実装だけを掘り下げます。
この記事で扱う関数
src/agents/techniques.ts には次の関数がまとまっています。
| 関数 | 役割 |
|---|---|
isLargeModel |
モデル名から大型/小型を判定する |
withCoT / withFewShot / withReAct
|
プロンプトに前置き・出力例・推論指示を追加する |
stripThinking |
ReAct が出力する <thinking> ブロックを除去する |
compressContext |
長すぎる分析結果を小型モデル向けに圧縮する |
parseStructuredOutput |
LLM の出力をセクション配列にパースする(3段フォールバック) |
runSelfConsistency / mergeConsistencyResults
|
3視点で並行生成し、結果をUnionでマージする |
呼び出し元は src/agents/runner.ts で、実際の分岐は以下のようになっています(runner.ts:52-67)。
const large = isLargeModel(model);
let system = customAgent
? withCoT(customAgent.systemPrompt, isJa)
: withCoT(prompts.system, isJa);
if (large) system = withReAct(system, isJa);
const buildUserPrompt = (extra = '') => {
const base = prompts.user(requestText + (extra ? `\n\n${extra}` : ''));
if (!large) {
const example = FEW_SHOT_EXAMPLES[roleId]?.[modeKey] ?? '';
return example ? withFewShot(base, example, isJa) : base;
}
return base;
};
CoT は常に付与、ReAct は大型モデルだけ、Few-Shot は小型モデルだけという分岐がここに集約されています。以下、個々の実装を見ていきます。
モデルサイズ判定: isLargeModel
判定ロジックは非常に単純です(techniques.ts:16-22)。
/** 大型モデルキーワード(ReAct を有効にするしきい値) */
const LARGE_MODEL_KEYWORDS = ['35b', '30b', '32b', '70b', '72b'];
export function isLargeModel(modelName: string): boolean {
const lower = modelName.toLowerCase();
return LARGE_MODEL_KEYWORDS.some((k) => lower.includes(k));
}
モデル名を小文字化して、5つの文字列のいずれかを部分一致で含むかを見るだけです。パラメータ数を数値としてパースするような処理は一切ありません。
実際に試すと次のようになります。
isLargeModel('qwen2.5:32b') // true
isLargeModel('deepseek-r1:70b') // true
isLargeModel('llama3.1:8b') // false
isLargeModel('qwen2.5:14b') // false
includes による部分一致なので、qwen2.5:32b-instruct-q4_K_M のようにタグが後ろに続いていても 32b の文字列さえ含まれていれば true になります。Ollama のモデルタグは <ファミリー名>:<サイズ>b-<量子化形式> という命名がほぼ定着しているため、正規表現でパラメータ数を抽出するより、この形の方が壊れにくいという判断です。ただし、この実装には弱点もあります。350b のような表記や、サイズを含まないカスタムタグ(例えば my-tuned-model:latest)には対応できません。Jin では Ollama の標準的な命名規則のモデルを使う前提でこの単純さを許容しています。
Chain-of-Thought・Few-Shot・ReActの前置き文字列
3つの手法はいずれも「文字列を組み立てて既存のプロンプトに足す」という素朴な実装です。
CoT はシステムプロンプトの先頭に定型文を連結します(techniques.ts:31-50)。
const COT_PREFIX_JA = `【思考手順】回答前に以下の順で考えてください:
1. 構想の核心と目的を把握する
2. 自分の専門領域から何が重要かを特定する
3. 見落としやすい観点・エッジケースを意識する
4. 上記を踏まえて各セクションを回答する
`;
export function withCoT(systemPrompt: string, isJa: boolean): string {
return (isJa ? COT_PREFIX_JA : COT_PREFIX_EN) + systemPrompt;
}
Few-Shot はユーザープロンプトの前後に出力例を挟み込みます(techniques.ts:58-66)。
export function withFewShot(userPrompt: string, example: string, isJa: boolean): string {
const header = isJa
? '---\n# 出力例(参考)\n'
: '---\n# Output example (reference)\n';
const footer = isJa
? '\n---\n# あなたが回答する構想\n'
: '\n---\n# Vision for your response\n';
return `${header}${example}${footer}${userPrompt}`;
}
example は駒(役割)ごとに用意された FEW_SHOT_EXAMPLES[roleId] から取り出されます(呼び出し側は前掲の runner.ts:63)。「出力例 → 実際の構想」という順番でユーザープロンプトに埋め込むことで、モデルにフォーマットを先に見せてから本題に入らせる構成です。
ReAct はシステムプロンプトの末尾に推論指示を追加します(techniques.ts:70-105)。
const REACT_INSTRUCTION_JA = `
【推論プロセス(ReAct)】
回答前に <thinking> タグ内で段階的に推論してください。
推論が終わったら <thinking> を閉じ、通常フォーマットで最終回答を出力してください。
例:
<thinking>
まず構想を分解すると…
考慮すべきリスクは…
実装順序は…
</thinking>
## セクション名
最終的な回答内容`;
export function withReAct(systemPrompt: string, isJa: boolean): string {
return systemPrompt + (isJa ? REACT_INSTRUCTION_JA : REACT_INSTRUCTION_EN);
}
3つとも実装は「テンプレート文字列を + で連結するだけ」です。特別なプロンプトテンプレートエンジンは使っていません。工夫が要るのはむしろこの後で、ReAct が生み出す <thinking> ブロックをどう後処理するかです。
<thinking>タグの除去: stripThinking
ReAct 指示を受け取ったモデルは <thinking>...</thinking> の中に思考過程を書いてから本回答を出力します。この中間生成物をユーザーに見せないための後処理が stripThinking です(techniques.ts:108-110)。
export function stripThinking(text: string): string {
return text.replace(/<thinking>[\s\S]*?<\/thinking>/gi, '').trim();
}
[\s\S]*? で改行を含む任意の文字列を非貪欲マッチし、gi フラグで大文字小文字を無視しつつ複数出現を全て除去します。動作確認は以下の通りです。
stripThinking('before<thinking>internal reasoning here\nmulti line</thinking>after')
// => 'beforeafter'
呼び出し側では large(大型モデル)のときだけこの処理を通します(runner.ts:87)。
const cleaned = large ? stripThinking(rawText) : rawText;
ReAct 指示自体を大型モデルにしか付与していないので、除去処理も大型モデルの出力にだけ適用すれば足ります。小型モデルの出力を無駄にこの正規表現に通す必要はありません。
長いコンテキストの圧縮: compressContext
Jin は分析フェーズの出力をそのまま実装フェーズのプロンプトに引き継ぎます。駒の数だけ分析結果が積み上がるため、小型モデルのコンテキスト窓を圧迫しがちです。これを緩和するのが compressContext です(techniques.ts:114-134)。
/** 小型モデルの文脈窓に収まるよう分析出力を圧縮するしきい値(文字数) */
const COMPRESS_THRESHOLD = 2500;
export function compressContext(text: string): string {
if (text.length <= COMPRESS_THRESHOLD) return text;
const parts = text.split(/^## /m).filter(Boolean);
return parts.map((part) => {
const lines = part.split('\n');
const label = lines[0]?.trim() ?? '';
const body = lines.slice(1).join('\n').trim();
const truncated = body.length > 200
? body.slice(0, 200) + '\n…(省略)'
: body;
return `## ${label}\n${truncated}`;
}).join('\n\n');
}
処理の流れは次の3ステップです。
- 全文の文字数が
COMPRESS_THRESHOLD(2500文字)以下ならそのまま返す - 超えていたら
/^## /mで##見出し単位に分割する。mフラグにより行頭の##にマッチするため、Markdown の見出し構造をそのままセクション境界として使える - 各セクションについて、1行目を見出しラベル、残りを本文として分離し、本文が200文字を超えていたら
slice(0, 200)で先頭200文字だけを残して…(省略)を付与する
「全体を均等に間引く」のではなく「セクションごとに先頭200文字だけを残す」点が特徴です。分析結果は見出しに続けて重要な結論を先に書く運用になっているため、先頭を残せば要点は失われにくいという前提に立っています。
実際に3000文字を超えるテキストで試すと次のようになりました。
const longBody = 'あ'.repeat(1000);
const text = `## セクションA\n${longBody}\n\n## セクションB\n${longBody}\n\n## セクションC\n${longBody}`;
text.length // 3034
compressContext(text).length // 652
3セクション×1000文字だった本文が、各セクション200文字+省略表記に切り詰められ、全体で652文字まで縮んでいます。呼び出し側では大型モデルにはこの圧縮をかけず、小型モデルにだけ適用しています(runner.ts:391, 396)。
const large = isLargeModel(model);
const fullAnalysis = analysisOutput.sections
.map((s) => `## ${s.label}\n${s.body}`)
.join('\n\n');
const analysisText = large ? fullAnalysis : compressContext(fullAnalysis);
構造化出力パースの3段フォールバック: parseStructuredOutput
LLM の出力を後段の処理で扱うには、セクションごとに構造化されたデータへ変換する必要があります。ただしモデルによって指示への追従度が違うため、parseStructuredOutput は3段階のフォールバックを持っています(techniques.ts:138-141, 149-176)。
export interface ParsedSection {
label: string;
body: string;
}
export function parseStructuredOutput(roleId: string, text: string): ParsedSection[] {
// ① JSON 形式を試みる
const jsonMatch = text.match(/\{[\s\S]*"sections"[\s\S]*\}/);
if (jsonMatch) {
try {
const parsed = JSON.parse(jsonMatch[0]) as { sections?: ParsedSection[] };
if (Array.isArray(parsed.sections) && parsed.sections.length > 0) {
return parsed.sections.filter((s) => s.label && s.body);
}
} catch { /* JSON パース失敗 → 次の手順へ */ }
}
// ② マークダウン `## ` 区切りでパース
const parts = text.split(/^## /m).filter(Boolean);
const sections: ParsedSection[] = [];
for (const part of parts) {
const lines = part.split('\n');
const label = lines[0]?.trim() ?? '';
const body = lines.slice(1).join('\n').trim();
if (label && body) sections.push({ label, body });
}
if (sections.length > 0) return sections;
// ③ フォールバック: テキスト全体を1セクション
return [{ label: roleId, body: text.trim() }];
}
3段の流れは次の通りです。
-
JSON抽出:
/\{[\s\S]*"sections"[\s\S]*\}/で"sections"を含む{...}ブロックを正規表現で切り出し、JSON.parseを試みる。モデルが説明文の前後に地の文を付けて JSON を返してきても、この正規表現で本体だけを拾える。パースに失敗しても例外を握りつぶして次の手順に進む -
Markdown見出し分割:
compressContextと同じ/^## /mで##単位に分割し、1行目をラベル、残りを本文としてParsedSectionを組み立てる。ラベルと本文が両方揃っている場合だけ採用する -
全文フォールバック: ①②のどちらでも1件もセクションが取れなかった場合、テキスト全体を
roleIdをラベルにした1セクションとして返す。ここまで来ると必ず何かしらのParsedSection[]が返るため、呼び出し側で「パース結果が空」を気にする必要がない
実際に3パターンを試した結果です。
// ① JSON
parseStructuredOutput('kin', 'prefix text {"sections":[{"label":"目的","body":"本文A"}]} suffix')
// => [{ label: '目的', body: '本文A' }]
// ② Markdown見出し
parseStructuredOutput('kin', '## 目的\n本文A\n\n## リスク\n本文B')
// => [{ label: '目的', body: '本文A' }, { label: 'リスク', body: '本文B' }]
// ③ フォールバック
parseStructuredOutput('kin', 'ただの文章です。見出しなし。')
// => [{ label: 'kin', body: 'ただの文章です。見出しなし。' }]
「JSON優先、失敗したらMarkdown、それも駄目なら全文そのまま」という順序にしているのは、Structured Outputに対応したモデルはJSONを返すことがある一方、大半のモデルは指示通り ## 見出しで返してくる、という現実の出力傾向に合わせたものです。
自己一貫性のUnionマージ: runSelfConsistency / mergeConsistencyResults
角(品質・リスク担当の駒)だけは、1回の生成で終わらせず3つの視点から並行生成してマージします(techniques.ts:187-207)。
export async function runSelfConsistency(
callFn: (extraContext: string) => Promise<string>,
isJa: boolean,
): Promise<string> {
const perspectives = isJa
? [
'【視点1: セキュリティ】脅威・認可不備・データ漏洩の観点を中心に分析してください。',
'【視点2: パフォーマンス】負荷・ボトルネック・スケーラビリティの観点を中心に分析してください。',
'【視点3: テスト・回帰】テスト漏れ・エッジケース・既存機能への影響を中心に分析してください。',
]
: [ /* 英語版は省略 */ ];
// 3視点を並行呼び出し
const results = await Promise.all(perspectives.map((p) => callFn(p)));
return mergeConsistencyResults(results, isJa);
}
Promise.all で3視点を並行に呼び出す点自体はよくある実装ですが、注目すべきはマージ処理です。一般に Self-Consistency というと「複数回生成して多数決を取る」実装を思い浮かべますが、Jin の mergeConsistencyResults は多数決ではなく Union(和集合) を取ります(techniques.ts:210-261)。
function mergeConsistencyResults(results: string[], isJa: boolean): string {
// セクションラベル → 項目セット
const sectionMap = new Map<string, Set<string>>();
// セクションの出現順を保持
const sectionOrder: string[] = [];
for (const result of results) {
const parts = result.split(/^## /m).filter(Boolean);
for (const part of parts) {
const lines = part.split('\n');
const label = lines[0]?.trim() ?? '';
const body = lines.slice(1).join('\n').trim();
if (!label) continue;
if (!sectionMap.has(label)) {
sectionMap.set(label, new Set());
sectionOrder.push(label);
}
// 箇条書き行を個別追加(重複は Set が除去)
const items = body
.split('\n')
.map((l) => l.trim())
.filter((l) => l.startsWith('-') || l.startsWith('*') || l.match(/^\d+\./));
if (items.length > 0) {
for (const item of items) sectionMap.get(label)!.add(item);
} else {
// 箇条書きでない場合は文章ごと追加
for (const line of body.split('\n').filter(Boolean)) {
sectionMap.get(label)!.add(line.trim());
}
}
}
}
// マージ結果を構築
const merged: string[] = [
isJa
? `## マージ情報\n3視点(セキュリティ・パフォーマンス・テスト)の結果をマージしています`
: `## Merge info\nResults merged from 3 perspectives (security, performance, testing)`,
];
for (const label of sectionOrder) {
const items = sectionMap.get(label)!;
if (items.size > 0) {
merged.push(`## ${label}\n${[...items].join('\n')}`);
}
}
return merged.join('\n\n');
}
ポイントは sectionMap の値の型が Set<string> になっていることです。多数決であれば「同じ項目が何回出現したか」をカウントして閾値以上のものだけ残す実装になりますが、ここでは出現回数を一切数えていません。3視点のどれか1つでも言及した項目は、そのまま最終結果に残ります。重複除去だけを Set に任せ、それ以外の足切りは行いません。
実際に runSelfConsistency へモックの callFn を渡して動作を確認しました。
const responses = [
'## リスク\n- SQLインジェクションのリスクがある\n- 認可漏れの可能性がある',
'## リスク\n- 負荷増大時にタイムアウトする恐れがある\n- 認可漏れの可能性がある',
'## リスク\n- テストカバレッジ不足\n- 認可漏れの可能性がある',
];
let i = 0;
const merged = await runSelfConsistency(async () => responses[i++], true);
出力は次の通りです。
## マージ情報
3視点(セキュリティ・パフォーマンス・テスト)の結果をマージしています
## リスク
- SQLインジェクションのリスクがある
- 認可漏れの可能性がある
- 負荷増大時にタイムアウトする恐れがある
- テストカバレッジ不足
3視点すべてに共通していた「認可漏れの可能性がある」は1件に重複除去される一方、各視点にしか出てこなかった「SQLインジェクション」「タイムアウト」「テストカバレッジ不足」もすべて残っています。多数決なら1視点にしか出なかった項目は切り捨てられますが、この実装は切り捨てません。リスク列挙という用途では、複数視点で一致した項目を信頼度で絞り込むよりも、どれか1つの視点が拾った懸念事項を取りこぼさない方が実用上重要だという判断です。
セクション分割に使っている正規表現 /^## /m は compressContext や parseStructuredOutput と同じものです。techniques.ts 内で ## 見出しをセクション境界とする前提が一貫して使われています。