はじめに:前回のおさらいと本稿の位置づけ
Part 1ではOpenClawのアーキテクチャ全体像とエージェントループの基本構造を解説した。本稿Part 2では、エージェントが実際に「ツールを呼び出す」瞬間に何が起きているのかを、リクエスト生成からパース、実行、結果統合という4段階のサイクルに分解し、コードレベルで追う。実装時にはまりがちな落とし穴と、レイテンシ・コスト両面での最適化ポイントも具体的に示す。
ツール呼び出しサイクルの全体像
Function Callingの内部フローは大きく4つのフェーズから構成される。
- リクエスト生成 ― ツールスキーマをシステムプロンプトに埋め込み、LLMへ送信する
- レスポンスパース ― LLMが返した構造化出力を検証・デシリアライズする
- ツール実行 ― 対応する関数を呼び出し、副作用を管理する
- 結果統合 ― ツール結果をコンテキストに戻し、次のターンへ渡す
Anthropicの公式ドキュメントでは、このサイクルを「multi-turn conversation」として定義しており、tool_useブロックとtool_resultブロックが交互にメッセージ配列へ追加される構造を採用している [Source: https://docs.anthropic.com/en/docs/build-with-claude/tool-use]。
フェーズ1:リクエスト生成とスキーマ設計
LLMがツールを正しく選択するには、JSON Schemaによるツール定義の品質が直接精度に影響する。以下はOpenClawにおける最小限のスキーマ定義例である。
tools = [ { "name": "search_web", "description": "Query the web and return top results. Use when real-time information is needed.", "input_schema": { "type": "object", "properties": { "query": {"type": "string", "description": "Search query string"}, "num_results": {"type": "integer", "default": 5} }, "required": ["query"] } } ] descriptionフィールドは単なる説明文ではなく、LLMがツール選択の判断に使うプロンプトそのものである。曖昧な記述はツール選択精度の低下を招く。
フェーズ2:レスポンスパースと検証
ClaudeのAPIはstop_reason: "tool_use"とともにcontent配列内にtool_useブロックを返す [Source: https://docs.anthropic.com/en/docs/build-with-claude/tool-use#handling-tool-use-and-tool-results]。OpenClawでは以下のようにパースと検証を一元化している。
def parse_tool_calls(response) -> list[ToolCall]: tool_calls = [] for block in response.content: if block.type == "tool_use": # Pydanticによる入力バリデーション validated = ToolInputValidator.model_validate(block.input) tool_calls.append(ToolCall(id=block.id, name=block.name, input=validated)) return tool_calls 落とし穴1:部分的なJSONの扱い ストリーミングレスポンスでツール引数をパースする場合、JSONが途中で切断されるケースがある。OpenClawではjson.JSONDecodeErrorをキャッチしてリトライキューに積む実装を採用している。
落とし穴2:ツール名の不一致 LLMが登録外のツール名を返すハルシネーションは稀に発生する。スキーマに存在しないツール名が来た場合はToolNotFoundErrorを発生させ、エラーメッセージをコンテキストに戻して再試行させるのが安全である。
フェーズ3:ツール実行と副作用管理
async def execute_tool(call: ToolCall) -> ToolResult: handler = TOOL_REGISTRY.get(call.name) if handler is None: return ToolResult(tool_use_id=call.id, is_error=True, content="Unknown tool: " + call.name) try: result = await asyncio.wait_for(handler(call.input), timeout=30.0) return ToolResult(tool_use_id=call.id, content=str(result)) except asyncio.TimeoutError: return ToolResult(tool_use_id=call.id, is_error=True, content="Timeout") 最適化ポイント1:並列実行 LLMが複数のツールを一度に要求した場合(parallel tool use)、asyncio.gatherで並列実行することでレイテンシを大幅に削減できる。Claude 3.5以降のモデルはparallel tool useを標準でサポートしており、複数のtool_useブロックを同一レスポンスで返すことがある。
最適化ポイント2:タイムアウト設計 ネットワーク依存ツールには必ずタイムアウトを設ける。タイムアウト値はツール種別ごとに設定し、設定ファイルで管理するとチューニングが容易になる。
フェーズ4:結果統合とコンテキスト管理
ツール実行結果はAnthropicのAPI仕様に従い、role: "user"のtool_resultブロックとしてメッセージ配列へ追加する。
def build_tool_result_message(results: list[ToolResult]) -> dict: return { "role": "user", "content": [ { "type": "tool_result", "tool_use_id": r.tool_use_id, "content": r.content, "is_error": r.is_error } for r in results ] } 落とし穴3:コンテキスト肥大化 ツール結果が大きい場合(例:Webページ全文)、そのままコンテキストに積むとトークン消費が急増する。OpenClawではツール結果に対してサマリーステップを挟み、重要情報のみを抽出してから統合する戦略を採用している。長大なシーケンスを扱う場合のメモリ効率はUlysses Sequence Parallelismのような研究でも指摘されており [Source: https://huggingface.co/blog/ulysses-sp]、コンテキスト長の管理はエージェント設計の根幹的課題である。
まとめと次回予告
本稿では、ツール呼び出しサイクルを4フェーズに分解し、各フェーズで遭遇する落とし穴と最適化ポイントをコードとともに示した。スキーマ設計・検証・並列実行・コンテキスト管理の4点が実装品質を左右する主要因である。
Part 3では、エージェントのメモリ管理とステート永続化に焦点を当て、長期タスクにおけるコンテキストウィンドウの効率的な活用方法を解説する。
Category: LLM | Tags: Function Calling, LLMエージェント, ToolUse, Claude, エージェント実装
0 件のコメント:
コメントを投稿