つれづれなる Agent OPS
つれづれなる Agent OPS
Flue

FlueのAgentはどこで失敗するのか Skill実行と構造化出力を切り分ける

Flue 1.0 BetaのIssueトリアージAgentで、通常の指示とSkillの明示実行を同じ入力で比べ、finish到達、schema検証、分類品質を分けて確認した検証メモ。

Xでシェア
Markdown を表示

この記事は「 Flue 実践入門 」シリーズの第 7 回です — 7 記事

Flue 1.0 BetaでIssueトリアージAgentを作ったとき、一番印象に残った失敗は、モデルが自然文ではfinishを理解しているように見えるのに、Flueが期待するfinish tool callへ到達できなかったことでした。

つれづれなる Agent OPS Flue 1.0 BetaでGitHub Issueトリアージエージェントを動かしてみた Flue 1.0 BetaのAgent・Skill・Workflowを使い、GitHub Issueのseverity、再現可否、ラベル候補を構造化して返すトリアージエージェントを作った検証ログ。 https://llm-lab.dev/posts/flue-1-0-beta-issue-triage-agent/

その後、Flueのobserve(...)イベントをLangfuseへ流し、成功ケースと失敗ケースをtraceとして見るところまで試しました。

つれづれなる Agent OPS FlueのobserveイベントをLangfuseへ流し、IssueトリアージAgentを観測する Flue 1.0 BetaのobserveイベントをredactionしたうえでLangfuseへ送信し、IssueトリアージWorkflowのrunId、モデル、結果を追う実験ログ。 https://llm-lab.dev/posts/flue-langfuse-observability-issue-triage/

ただ、ここで一つ気になっていました。 IssueトリアージAgentでは、Workflowからsession.prompt(..., { result })を呼んでいました。では、FlueのSkillを明示的に呼ぶsession.skill('triage', { result })でも同じようにstructured outputを受け取れるのでしょうか。

公式ドキュメント上は、prompt()skill()task()はいずれもoptions.resultへValibot schemaを渡せます。つまり、APIとしては比較する意味があります。今回は同じIssue payloadを、session.prompt()経由とsession.skill()経由の2本に流し、どこまで同じで、どこから評価観点が分かれるのかを見ました。

先に結論

今回の入力では、session.prompt(..., { result })session.skill('triage', { args, result })の両方が成功し、どちらもValibot schemaに合う構造化結果を返しました。

一方で、完全に同じ出力にはなりませんでした。severityとreproducibleは一致しましたが、labelsとsummaryの粒度は少し変わりました。これは失敗ではなく、同じschemaに収まる範囲で、モデルの判断や呼び出し経路による揺れが残るという話です。

session.promptとsession.skillの両方でstructured outputが成功した比較結果

ここで分かったことは、session.skill()がstructured outputと相性が悪いという話ではありません。むしろ、session.skill()でも構造化結果は受け取れます。ただし、Agentフレームワークで見るべき層は、API接続、tool protocol、schema validation、出力品質に分かれます。以前のAgent did not call finishは、schema以前にtool protocolの層で止まった失敗として扱うべきです。

比較用Workflowを作る

検証では、既存のIssueトリアージAgentとtriage Skillを再利用し、比較用のWorkflowだけを追加しました。

export async function run({ init, payload }: FlueContext<IssuePayload>) {
	const harness = await init(issueTriage);
	const promptSession = await harness.session('prompt-result');
	const skillSession = await harness.session('skill-result');

	const promptResult = await captureInvocation('prompt-result', async () => {
		const response = await promptSession.prompt(`Use the triage skill to classify this GitHub issue.

Title:
${payload.title}

Body:
${payload.body}

Return only the structured triage result.`, {
			result: TriageResult,
		});

		return response.data;
	});

	const skillResult = await captureInvocation('skill-result', async () => {
		const response = await skillSession.skill('triage', {
			args: {
				title: payload.title,
				body: payload.body,
			},
			result: TriageResult,
		});

		return response.data;
	});

	return {
		input: {
			title: payload.title,
			bodyCharacters: payload.body.length,
		},
		results: [promptResult, skillResult],
	};
}

ポイントは、prompt-resultskill-resultを別セッションにしたことです。同じセッションで連続実行すると、前半の会話履歴が後半の判断に混ざる可能性があります。今回見たいのは、同じAgent、同じschema、同じpayloadで、prompt()からSkill利用を指示する経路と、skill()でSkillを明示実行する経路の差です。そのため、セッション名を分け、比較対象をなるべく細くしました。

schemaは既存のIssueトリアージと同じです。

const TriageResult = v.object({
	severity: v.picklist(['low', 'medium', 'high', 'critical']),
	reproducible: v.boolean(),
	labels: v.array(v.string()),
	summary: v.string(),
});

session.skill()へ渡すargsは、Skillの入力として扱われます。今回のtriage Skillにも、prompt内で渡されたIssue title/bodyだけでなく、args.titleargs.bodyがある場合はそれを使う、という一文を足しました。

Read the issue title and body provided in the prompt. If this skill is invoked
with `args.title` and `args.body`, use those values as the issue title and body.

これを入れないと、Skill側のMarkdownだけを読んだモデルが「どこにIssue本文があるのか」を探す余地が残ります。Skillを再利用可能な作業単位にしたいなら、引数として渡す値とSkill本文の期待をそろえておくほうがよさそうです。

検証用コマンドの位置づけ

実行には、記事用に追加した検証用npm scriptを使いました。これはFlueの標準コマンドそのものではなく、内部でflue run compare-skill-prompt --target node --payload ...を呼ぶ薄い入口です。

npm run compare:skill-prompt -- '{"title":"Dashboard is blank after login","body":"Steps: log in, open /dashboard. Expected widgets. Actual blank white screen in Chrome 126."}'

入力は、Issueのtitlebodyです。対象は比較用Workflowで、確認しているのは次の3点です。

  • session.prompt(..., { result })が、triage Skillの利用を指示されたprompt経路としてschemaに合う結果を返すか
  • session.skill('triage', { args, result })が、triage Skillを明示実行する経路として同じschemaに合う結果を返すか
  • どちらもFlueの完了条件であるfinishまで到達するか

実行結果では、両方ともsuccessになりました。

{
  "input": {
    "title": "Dashboard is blank after login",
    "bodyCharacters": 90
  },
  "results": [
    {
      "route": "prompt-result",
      "status": "success",
      "data": {
        "severity": "high",
        "reproducible": true,
        "labels": ["bug", "frontend", "dashboard"],
        "summary": "ダッシュボードにログイン後、ウィジェットが表示されるはずの画面が真っ白になるという問題。Chrome 126で再現可能とのこと。再現手順は「ログイン後 /dashboard を開く」と明記されており、事象は明確。ただし、コンソールエラーやサーバーログ、他ブラウザでの確認結果などの詳細情報は不足している。"
      }
    },
    {
      "route": "skill-result",
      "status": "success",
      "data": {
        "severity": "high",
        "reproducible": true,
        "labels": ["bug", "dashboard", "ui"],
        "summary": "ログイン後にダッシュボード(/dashboard)へアクセスすると、ウィジェットが表示されるべきところが空白の白い画面になってしまう。Chrome 126で発生。"
      }
    }
  ]
}

ここで面白いのは、結果が「同一」ではなく「同じschema内で少し違う」ことです。prompt-resultfrontendを含め、summaryで追加情報不足にも触れています。一方、skill-resultuiを含め、要約は短めです。どちらも不正なJSONではなく、schemaとしては有効です。

つまり、structured outputは出力形式を固定しますが、判断内容まで完全には固定しません。ここを混同すると、「JSONで返ったから評価完了」と見誤ります。後続処理に渡せる形で返っているかと、分類として十分かは別の評価軸です。

以前のfinish失敗はどの層の失敗か

以前の失敗ログでは、モデルはfinishに言及していました。しかし、Flueの実行プロトコルとして必要なtool callを返せず、最終的に次のエラーになりました。

The agent gave up: Agent did not call `finish` or `give_up` after 33 attempts.

この失敗を、単に「structured outputに失敗した」と呼ぶと少し粗いです。今回の比較で整理すると、層は次のように分かれます。

Agent did not call finishをAPI接続、protocol、schemaの層に分けた図

API接続の層では、providerがリクエストを受け、モデル出力を返せていました。自然文の応答も出ています。ところが、Flueが期待するfinish tool callへ到達できませんでした。この場合、Valibot schemaに合うかどうかはまだ評価できません。schema validationは、Agentが完了プロトコルを満たした後に初めて意味を持つからです。

え、文章では分かってるっぽいのに、そこを越えられないのか。

この違和感が、Agentフレームワークの検証ではかなり重要です。単発のLLM API利用なら、自然文としてそれらしい応答が返れば「動いた」と見なす場面もあります。しかしFlueのようにSkill、Tool、Workflow、structured outputを組み合わせる場合、モデルは単に答えを書くのではなく、フレームワークが要求する操作列に従う必要があります。

session.skillを使う判断基準

今回触った範囲では、session.skill()は「Skillを明示的に実行するWorkflow」と相性がよさそうです。たとえば、Issueトリアージ、レビュー、要約、分類のように、毎回同じ判断基準を呼び出したい処理です。

一方、session.prompt()は、都度の指示文を柔軟に組み立てたい場合に向いています。既存のIssueトリアージWorkflowでは、prompt内で「このIssueを分類して、structured resultだけ返して」と明示していました。この形は読みやすく、実装も小さいです。

ただ、判断基準をSkillとして管理しているなら、Workflow側でsession.skill('triage', { args, result })を使うほうが、責務の分離ははっきりします。Workflowは入力を受け、Skillへ引数を渡し、schema付きの結果を受け取る。分類基準はMarkdownのSkillに閉じる。この形のほうが、後からSkillだけを見直したり、別Workflowから同じSkillを呼んだりしやすくなります。

Eval対象として見るべきもの

今回の比較から、FlueのIssueトリアージをEvalにするなら、少なくとも次を分けて見るべきだと感じました。

観点確認すること失敗例
接続model specifier、認証、base URLが通るかUnknown model specifier、401、接続失敗
protocolactivate_skillfinishへ到達するかAgent did not call finish
schemaValibot schemaに合う構造化結果か必須項目欠落、型不一致
品質severityやlabelsが妥当かschemaは合うが分類が荒い
安定性同じpayloadで呼び出し経路やモデルを変えても崩れないかlabel候補やsummaryの揺れ

特に「モデル交換可能」と「同じ実行プロトコルで安定する」は別です。OpenAI互換APIに向けられる、レスポンスが返る、自然文では意味が通る、Flueのfinishまで到達する、schemaを満たす、分類品質が十分である。これらは一本の成功判定ではなく、段階的に見るべきです。

まとめ

session.skill()とstructured outputの相性は、少なくとも今回のIssueトリアージでは問題ありませんでした。同じpayloadをsession.prompt(..., { result })session.skill('triage', { args, result })に通し、どちらもfinishまで到達してValibot schemaに合う結果を返しました。

ただし、それは「どのモデルでも安定する」という意味ではありません。以前のAgent did not call finishは、structured outputのschema以前に、Flueのtool protocolへ追従できなかった失敗です。ここを分けると、モデル選定やEval設計がかなり整理しやすくなります。

個人的には、Flueの実用検証では、成功ログだけでなく「どの層で失敗したか」を残すほうが価値が高いと感じています。Agentは自然文を返すだけの処理ではなく、フレームワーク上の操作列を完了させる実行主体です。だからこそ、APIが通った、モデルが返した、finishできた、schemaに合った、分類として妥当だった。この5つを分けて見るのが、FlueのようなAgentフレームワークを運用に寄せるときの土台になりそうです。

DUOps

Author

DUOps(デュオプス)

LLMOps、Agent、MCP、Langfuse、Cloudflare 周辺の実装と運用を、個人で試しながら記録しています。

Xを見る

コメント

Related