
LLM にアクションを選択させて 犬型ロボット Unitree Go2 を動かしてみた
はじめに
こんにちは!AI 事業本部のこーすけです。
前回の記事では四足歩行の犬型ロボット Unitree Go2 を Python SDK で歩かせるところまでを紹介しました。今回は、AI にアクションを選択させ、その結果でロボットを動かす仕組みを作ったので紹介します。現在 Go2 を AI エージェント的に動かすデモを開発しており、今回はその第一歩として、テキスト入力からロボットが動くまでの一連のパイプラインを作成し検証しました。
やりたいこと
最終的には、Go2 に搭載されたカメラやマイクからの入力を AI に渡して、状況に応じた動作を自律的に選択・実行する「AI エージェント的な制御」を実現したいと考えています。出力に関しても、Go2 にはスピーカーが搭載されているので、ロボット本体の動きに加えて音声応答など様々なインタラクションに広げていけたら活用の幅が広がりそうです。
ただ、いきなりそこを目指すのはハードルが高いです。そのため今回はミニマムな構成として以下を検証しました。
- テキストで指示を入力する(例:「前に進んで」)
- Bedrock の Converse API(Tool Use)がアクションを選択する
- 選択されたアクションに基づいてロボットが動く
※「アクションを選択する」については「設計時に考えたこと」の章で説明します

Bedrock Converse API と Tool Use
今回、LLM によるアクション選択には Amazon Bedrock の Converse API を使用しました。Converse API は Bedrock が提供する対話 API で、モデルに関わらず同じインターフェースでテキスト生成や Tool Use を利用できます。
Tool Use(Function Calling) は、LLM にあらかじめ定義した「ツール」の中から適切なものを選択・呼び出しさせる機能です。通常はデータベース検索や外部 API 呼び出しなどに使われますが、今回はこれを後述する ロボットのアクション選択 に応用しました。LLM がフリーテキストではなく、定義済みのツール名を構造化された形式で返してくれるため、パース失敗や未定義の動作が実行される事故を防ぎやすく、物理的なリスクを伴うロボット制御との相性が良いと考えました。
設計時に考えたこと
LLM にロボットを制御させる素朴な方法は、動作そのもの(例:前進する)と、そのパラメータ(例:0.5 m/s で 3 秒間)の両方を LLM に生成させるアプローチです。しかしこの方法では、「10 m/s で 30 秒前進」のような危険な値が返ってきてロボットが暴走するリスクがあります。物理的なロボットを動かす以上、安全面は妥協できません。
そこで今回は、ロボットが動作可能な「動作とそのパラメータ」をパッケージにしてそれを「アクション」として定義しておき、LLM には実行する「アクション」を選ばせるという方針にしました。
具体的には、前回の記事で紹介した SDK の SportClient が提供する Move() や StopMove() を組み合わせた一連の動作(例:0.5 m/s で 1 秒間前進して停止する)を、ひとつの「アクション」としてパッケージ化しておき、速度や時間といった物理パラメータはすべてこのアクション定義の中に固定値として持たせ、LLM にはアクション名を選ぶだけにしました。
アーキテクチャ
この方針をもとに、以下の 4 層構成で設計しました。

| 層 | 責務 | 今回の実装方法 |
|---|---|---|
| 入力層 | カメラ・マイクなどの入力を収集 | 未実装(今回はテキスト入力で代替) |
| エージェント層 | LLM が状況を解釈し、Tool Call でアクションを選択 | Bedrock Converse API(Tool Use) |
| アクション定義層 | 指示を SDK コマンドに変換 | ActionDefinition(固定パラメータ) |
| ロボット制御層 | Unitree Go2 の SportClient で実際のモーター制御 | RobotController(SportClient ラップ) |
入力層は今後の発展としてカメラの映像や音声を扱うために用意していますが、今回の検証ではテキスト入力で代替しています。今回実装したのはエージェント層〜ロボット制御層の 3 層です。以下でそれぞれの実装をご紹介します。
実装
アクション定義層
まずはアクションの型定義です。設計思想は LLM に動作のパラメータ自体をカスタムする機能を持たせないということです。
@dataclass(frozen=True)
class ActionDefinition:
"""Action Registryに登録するアクション定義
LLMが選択できるアクションを事前定義する。
velocity / duration は固定値で、APIレスポンスからは変更できない。
"""
name: str
description: str
velocity: float # m/s(符号で方向を表す: 正=前進, 負=後退)
duration: float # 秒
frozen=True にしているので、インスタンス生成後に値を変更できません。LLM がどんなパラメータを返そうとも、アクション定義の速度や時間が書き換わることは構造的にありえないようにします。
この型を使って実行可能なアクションを定義しました。
今回はシンプルな検証となるよう、前進するアクション(move_forward)と後退するアクション(move_backward)の二つを定義しました。
MOVE_FORWARD = ActionDefinition(
name="move_forward",
description="1秒間前進する",
velocity=0.5,
duration=1.0,
)
MOVE_BACKWARD = ActionDefinition(
name="move_backward",
description="1秒間後退する",
velocity=-0.5,
duration=1.0,
)
ACTION_REGISTRY: dict[str, ActionDefinition] = {
MOVE_FORWARD.name: MOVE_FORWARD,
MOVE_BACKWARD.name: MOVE_BACKWARD,
}
ポイントは、velocity=0.5(0.5 m/s)や duration=1.0(1 秒)がコードにハードコードされていることです。
エージェント層:Bedrock Converse API の呼び出し
続いて、エージェント層の実装を見ていきます。Bedrock Converse API を呼び出してアクションを選択させるモジュール全体は以下のとおりです。
@dataclass(frozen=True)
class ToolInputSchema:
"""Bedrock Converse API の inputSchema"""
type: str = "object"
properties: dict[str, Any] = field(default_factory=dict)
required: list[str] = field(default_factory=list)
@dataclass(frozen=True)
class ToolDefinition:
"""Bedrock Converse API に送信する tool 定義
ActionDefinition から生成し、API リクエストの tools 引数に渡す。
"""
name: str
description: str
input_schema: ToolInputSchema = field(default_factory=ToolInputSchema)
@dataclass
class ToolUseResponse:
"""Bedrock Converse API の toolUse レスポンスから抽出した情報"""
tool_use_id: str
action_name: str
SYSTEM_PROMPT = (
"あなたは犬型ロボット Unitree Go2 の行動を制御するAIです。"
"与えられた状況に応じて、最も適切なアクションをひとつ選択してください。"
"必ずいずれかのアクションを実行してください。"
)
MODEL_ID = "jp.anthropic.claude-sonnet-4-5-20250929-v1:0"
def to_tool_definition(action: ActionDefinition) -> ToolDefinition:
"""ActionDefinition を Bedrock Converse API の ToolDefinition に変換する"""
return ToolDefinition(
name=action.name,
description=action.description,
input_schema=ToolInputSchema(),
)
def build_tools_param(
actions: dict[str, ActionDefinition],
) -> list[dict]:
"""ACTION_REGISTRY から Bedrock Converse API の toolSpec リストを生成する"""
tools = []
for action in actions.values():
td = to_tool_definition(action)
tools.append(
{
"toolSpec": {
"name": td.name,
"description": td.description,
"inputSchema": {
"json": {
"type": td.input_schema.type,
"properties": td.input_schema.properties,
"required": td.input_schema.required,
}
},
}
}
)
return tools
def parse_tool_use(response: dict) -> ToolUseResponse:
"""Bedrock Converse レスポンスから toolUse ブロックを抽出する"""
for block in response["output"]["message"]["content"]:
if "toolUse" in block:
tool_use = block["toolUse"]
return ToolUseResponse(
tool_use_id=tool_use["toolUseId"],
action_name=tool_use["name"],
)
raise ValueError("レスポンスに toolUse ブロックが含まれていません")
def call_agent(
prompt: str,
actions: dict[str, ActionDefinition],
) -> ToolUseResponse:
"""Bedrock Converse API を呼び出してアクションを選択させる"""
client = boto3.client("bedrock-runtime")
response = client.converse(
modelId=MODEL_ID,
system=[{"text": SYSTEM_PROMPT}],
messages=[
{
"role": "user",
"content": [{"text": prompt}],
}
],
toolConfig={
"tools": build_tools_param(actions),
"toolChoice": {"any": {}},
},
inferenceConfig={"maxTokens": 256},
)
return parse_tool_use(response)
いくつかポイントをピックアップします。
ToolInputSchema():propertiesが空のinputSchemaを生成します。これにより、LLM が動作のパラメータを一切指定できない構造になっています。parse_tool_use():Converse API のレスポンスからtool_use["name"]、つまり ツール名(=アクション名)だけ を取り出します。速度や時間といったパラメータは一切レスポンスに含めません。toolChoice: {"any": {}}:必ずいずれかの tool を選択させます。LLM が「わかりません」などとテキストだけ返すことが構造的に不可能になります。ただし、何もアクションをしないことが正解の状況も考えられるので、今後の調整課題です。
ロボット制御層:unitree_sdk2_python の呼び出し
API から返されたアクション名を元に、ロボットを動かす部分です。前回の記事で紹介した SDK の SportClient をラップしています。
class RobotController:
def execute(self, action: ActionDefinition) -> ActionResult:
"""ActionDefinition に基づいてロボットを動かす"""
try:
self._sport_client.Move(action.velocity, 0, 0)
time.sleep(action.duration)
self._sport_client.StopMove()
return ActionResult(
action_name=action.name,
status=ActionStatus.SUCCESS,
message=f"{action.name} を実行しました",
)
except Exception as e:
self._sport_client.StopMove()
return ActionResult(
action_name=action.name,
status=ActionStatus.FAILURE,
message=f"{action.name} の実行に失敗しました: {e}",
)
前回の記事で触れたとおり、SportClient.Move() を呼ぶとロボットが動き始め、StopMove() で停止します。ここでは ActionDefinition の固定値(action.velocity / action.duration)だけを使うので、LLM のレスポンスがロボットの速度や動作時間に影響することはありません。また、例外が発生しても StopMove() を呼ぶことで、ロボットが動き続けることを防いでいます。
動作検証
Step 1:API 単体の動作確認
まずはロボットを接続せずに、Bedrock の API 呼び出しだけを検証しました。以下のスクリプトでターミナルからテキストを入力し、LLM が選択したアクションを確認しました。
def main():
print("エージェント動作確認(ロボット接続なし)")
print(f"登録済みアクション: {list(ACTION_REGISTRY.keys())}")
while True:
user_input = input("\n指示を入力してください: ")
print("エージェントに問い合わせ中...")
response = call_agent(user_input, ACTION_REGISTRY)
action = ACTION_REGISTRY.get(response.action_name)
if action is None:
print(f"未登録のアクション: {response.action_name}")
continue
print(f"選択されたアクション: {action.name}")
print(f" 説明: {action.description}")
print(f" 速度: {action.velocity} m/s")
print(f" 時間: {action.duration} 秒")
いくつかの入力を試してみました。
指示を入力してください: 前に進んで
エージェントに問い合わせ中...
選択されたアクション: move_forward
説明: 1秒間前進する
速度: 0.5 m/s
時間: 1.0 秒
指示を入力してください: 下がって
エージェントに問い合わせ中...
選択されたアクション: move_backward
説明: 1秒間後退する
速度: -0.5 m/s
時間: 1.0 秒
「前に進んで」「下がって」の入力に対して正しくアクションが選ばれました。現状は 2 択なので判断がシンプルですが、アクションが増えてきたときの LLM の判断力も今後検証していきます。
Step 2:ロボットとの接続
API の動作確認ができたので、次はロボットとの接続です。前回の記事で紹介した SDK のセットアップが済んでいれば、あとは API のレスポンス(アクション名)を受け取って、対応する SDK のメソッドを呼ぶだけです。実際に動作している様子は次の「デモ」章の動画でご確認ください。

実際のメインループは以下のとおりです。
def main():
network_interface = sys.argv[1] if len(sys.argv) > 1 else None
controller = RobotController(network_interface)
print("ロボット制御を開始します。終了するには Ctrl+C を押してください。")
while True:
user_input = input("\n指示を入力してください: ")
print(f"エージェントに問い合わせ中...")
response = call_agent(user_input, ACTION_REGISTRY)
print(f"選択されたアクション: {response.action_name}")
action = ACTION_REGISTRY.get(response.action_name)
if action is None:
print(f"未登録のアクション: {response.action_name}")
continue
result = controller.execute(action)
print(f"結果: {result.status.value} - {result.message}")
コアとなる処理は call_agent() → ACTION_REGISTRY からの辞書引き → controller.execute() の 3 行です。
デモ

「前進して」と入力 → Bedrock API が move_forward を選択 → Go2 が前進する、「後退して」と入力 → Bedrock API が move_backward を選択 → Go2 が後退する、という一連の流れが動作しました。
この先について
今回の検証では Bedrock の API をクラウド経由で呼び出しています。テキスト入力ベースであればさほど問題が表面化しませんが、カメラ映像をリアルタイムで解析して即座に動作を切り替えるような制御では、API コールのレイテンシーがボトルネックになります。
そこで注目されているのが、AI の推論をロボット本体上で実行するエッジ推論です。ロボット AI の分野では、VLA(Vision-Language-Action)モデルのように視覚・言語・行動を一気通貫で扱うアプローチと併せて、エッジ推論の活用が急速に進んでいます。Go2 EDU は NVIDIA Jetson Orin を搭載しているので、将来的にはエッジ側での推論も選択肢に入ってきます。
次のステップでは、カメラ画像からの状況判断や音声入力への対応を進めていく予定です。
おわりに
今回は、Bedrock の Converse API を使って Unitree Go2 のアクションを選択し、実際にロボットを動かすところまでを検証しました。
設計上のポイントは「LLM にはアクションの選択だけをさせ、物理パラメータは人間が決める」という点です。Tool Use の仕組みを活用することで、LLM の出力を構造的に制約できるのは、ロボット制御のような安全性が求められる場面と相性が良いと感じました。
最後までお読みいただき、ありがとうございました。








