技術 約15分で読めます

ModelScope経由のQwen3.7 Plusでfunction callingが通ったので対話型エージェントを自作した

いけさん目次

Qwen3.7 Plusを、ModelScope経由のOpenAI互換エンドポイントで叩けるようになった。Qwen3.7で日本語小説を校正するのに使ってみたが、エージェントとして動かせるのかは別の話だ。今回はそれを試す。

Qwen3.7はエージェント用途を前面に出した世代でもある。Alibabaはfunction calling、長時間の自律実行、それにどのエージェント基盤でも同じように動く点(Claude Code、OpenClaw、自前のコードを問わない)を売りにしている。だとすれば、専用フレームワークなしのありもののループでどこまで素直に動くのか、が試しどころになる。

エージェントとして動かすには、モデルがツールを呼べること(function calling)と、それを回すループの2つが要る。ただしModelScopeのOpenAI互換エンドポイントがツール呼び出しを通すかどうかは、叩いてみないと分からない。通れば openai SDKだけで「モデルがツールを要求する → こちらが実行して結果を返す → モデルが続ける」というループを自前で書けるし、通らなければプロンプトにツールを書き並べてモデルの出力をパースするReAct方式に切り替えることになる。

そこを確かめてから、最小のツールループ(内側)、対話型に広げる履歴保持(外側)、履歴を溜めっぱなしにするとどこで詰まるか、と順に試した。全部ローカルの実行ログ付きで残す。

検証環境

項目内容
クライアントApple M1 Max / 64GB / macOS 26.5
言語・実行Python 3.13 / uv 0.10
SDKopenai 2.41
接続ModelScope経由のOpenAI互換API
モデルQwen3.7 Plus
パラメータ検証は temperature=0、対話は temperature=0.3

モデルの推論はAPI側で走るので、ローカルのマシンスペックは関係ない。M1 MaxはただのAPIクライアントとして動いているだけ。OpenAI互換APIなら、base_urlとAPIキーとモデル名を自分の環境に合わせて差し替えれば、同じコードがそのまま動く。

エージェントの本体は二重ループ

エージェントと呼ばれているものの中身は、2つのループが入れ子になっているだけだ。

内側のループは、1つの指示を片付けるためのツール往復。モデルにメッセージを送り、モデルが「このツールをこの引数で呼べ」と返してきたら、こちらが実際に関数を実行し、結果をメッセージ列に足してまた送る。モデルがツールを要求しなくなったら、その回答が最終出力になる。

外側のループは、会話そのもの。入力を待ち、メッセージ列に積み、内側のループを回し、また次の入力を待つ。このメッセージ列を会話のあいだ保持し続けるかどうかが、単発のAPI呼び出しと対話型エージェントの違いになる。

flowchart TD
    U[入力] --> A[messagesに追加]
    A --> L[Qwenに送信]
    L --> D{tool_callsがある?}
    D -->|あり| T[ツールを実行]
    T --> R[結果をmessagesに追加]
    R --> L
    D -->|なし| O[最終回答を表示]
    O --> U

Qwenに送信してtool_callsの有無を判定し、あればツールを実行して結果をmessagesに足してまた送信する。この小さな閉路が内側のループ。ツール要求が尽きたら最終回答を表示し、次の入力を待つ。この大きな閉路が外側のループにあたる。コードもこの図のまま書ける。

function callingは、このうちモデルが「ツールを呼べ」と構造化して返してくる部分の規格だ。ツールはモデルに最初から備わっているわけではない。こちらが tools(JSON Schemaでツール名・説明・引数を定義した配列)で「こういう関数が使える」と毎回教えた分だけ、モデルは要求できる。モデルがレスポンスの tool_calls にツール名と引数のJSON文字列を入れて返してきても、モデル自身は関数を実行しない。関数の実体を用意して動かすのはこちら側で、結果を roletool のメッセージとして返してやる。

内側のループでfunction callingを試す

エンドポイントが tools を受け付けるかをまず確かめる。ツールはこちらであらかじめ用意した。天気を返すダミー関数と整数を足す関数を定義して tools に並べ、両方が要る質問を投げた。

import json, os
from openai import OpenAI

client = OpenAI(
    api_key=os.environ["QWEN_API_KEY"],
    base_url=os.environ["QWEN_BASE_URL"],   # OpenAI互換エンドポイント
)
MODEL = os.environ["QWEN_MODEL"]            # 使うQwenのモデル名

tools = [
    {
        "type": "function",
        "function": {
            "name": "get_weather",
            "description": "指定した都市の現在の天気を返す",
            "parameters": {
                "type": "object",
                "properties": {"city": {"type": "string"}},
                "required": ["city"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "add",
            "description": "2つの整数を足す",
            "parameters": {
                "type": "object",
                "properties": {"a": {"type": "integer"}, "b": {"type": "integer"}},
                "required": ["a", "b"],
            },
        },
    },
]

def get_weather(city):
    return f"{city}は晴れ、気温24度"

def add(a, b):
    return a + b

DISPATCH = {"get_weather": get_weather, "add": add}

messages = [{"role": "user", "content": "東京の天気を教えて。あと 17 と 25 を足すといくつ?"}]
while True:
    r = client.chat.completions.create(
        model=MODEL, messages=messages, tools=tools, temperature=0)
    msg = r.choices[0].message
    messages.append(msg.model_dump(exclude_none=True))
    if not msg.tool_calls:               # ツール要求がなければ完了
        print(msg.content)
        break
    for tc in msg.tool_calls:            # 要求されたツールを実行して返す
        args = json.loads(tc.function.arguments)
        result = DISPATCH[tc.function.name](**args)
        messages.append(
            {"role": "tool", "tool_call_id": tc.id, "content": str(result)})

実行ログ。

[turn=0] tool_call: get_weather({'city': '東京'}) -> 東京は晴れ、気温24度
[turn=0] tool_call: add({'a': 17, 'b': 25}) -> 42
[turn=1] 最終回答: 東京の天気は晴れ、気温は24度です。また、17 と 25 を足すと 42 になります。

1回目のレスポンスで get_weatheradd が同時に返ってきている。並列tool_callsだ。両方を実行して結果を返すと、2回目で2つの結果をまとめた最終回答になった。ReActへのフォールバックは要らず、ネイティブのfunction callingがそのまま通る。Qwen系はもともと関数呼び出しのテンプレートを持っているとはいえ、ModelScopeの推論エンドポイントがそれを素通しするかは叩くまで分からなかったので、ここは確かめておきたかった。

外側のループで対話型にする

内側のループに、入力を待つ外側のループを巻く。違いは、メッセージ列をループの外で持ち、発言をまたいで消さないこと。これだけで会話の文脈を覚える。

SYSTEM = "日本語で答える親切なアシスタント。必要に応じてツールを使う。"

def main():
    messages = [{"role": "system", "content": SYSTEM}]   # 会話をまたいで保持
    while True:                                  # 外側 会話ループ
        user = input("\n入力> ").strip()
        if user in {"exit", "quit", ""}:
            break
        messages.append({"role": "user", "content": user})
        while True:                              # 内側 ツール往復ループ
            r = client.chat.completions.create(
                model=MODEL, messages=messages, tools=tools, temperature=0.3)
            msg = r.choices[0].message
            messages.append(msg.model_dump(exclude_none=True))
            if not msg.tool_calls:
                print(f"\nQwen> {msg.content}")
                break
            for tc in msg.tool_calls:
                args = json.loads(tc.function.arguments)
                result = DISPATCH[tc.function.name](**args)
                messages.append(
                    {"role": "tool", "tool_call_id": tc.id, "content": str(result)})

main()

2発言を流し込んだログ。

入力> 東京の天気は?
  [tool] get_weather({'city': 'Tokyo'}) -> Tokyoは晴れ、気温24度
Qwen> 東京の現在の天気は晴れ、気温は24度です。

入力> さっき聞いた都市の気温を、華氏に換算するといくつ?
Qwen> さっきの東京の気温は摂氏24度でしたので、華氏に換算すると75.2度。
      計算式は 24 × 9/5 + 32 = 75.2。

2発言目の「さっき聞いた都市」で、get_weather を呼び直さずに履歴から東京・24度を引き当てて換算している。ツールの結果まで含めた過去のやり取りが全部 messages に残っているからだ。これが対話型エージェントの実体で、特別な記憶機構があるわけではなく、ただメッセージ列を捨てずに持っているだけ。

スキルの仕組みも作れる

ツールを増やすのと同じ要領で「スキル」も作れる。ここで言うスキルは、特定の作業の手順をまとめた指示書を、必要なときだけモデルに読み込ませる仕組みのこと。互換性の話ではなく、自前でその仕様を組めるかを試す。

やり方はシンプルで、スキルの手順を辞書に持っておき、それを読み込む use_skill というメタツールを1つ用意するだけ。モデルは作業に合いそうなスキルを use_skill で呼び、返ってきた手順を読んでから従う。

SKILLS = {
    "proofread": {
        "description": "日本語テキストの誤字脱字を校正する",
        "instructions": (
            "次の手順で校正する。"
            "1. 誤字・脱字・衍字だけを抜き出す。"
            "2. 各箇所を「誤 → 正(理由)」の形式で列挙する。"
            "3. 方言・話し言葉・固有名詞は直さない。"
        ),
    },
    "haiku": {
        "description": "内容を五七五の俳句にする",
        "instructions": "与えられた内容を、五・七・五の俳句一句にして返す。季語を一つ入れる。",
    },
}

tools = [{
    "type": "function",
    "function": {
        "name": "use_skill",
        "description": "登録済みスキルの手順を読み込む",
        "parameters": {
            "type": "object",
            "properties": {"name": {"type": "string", "enum": list(SKILLS)}},
            "required": ["name"],
        },
    },
}]

def use_skill(name):
    return SKILLS[name]["instructions"]

校正を頼むと、モデルは自分で proofread を選んで読み込み、その手順どおりに直した。

task: この文を校正して: 今日はとてもいい天気ですた。明日もはれるといいですね。
[use_skill] proofread の手順を読み込み
最終回答:
- ですた → でした(「た」は「だ」の誤字。話し言葉では使われるが標準表記は「でした」)
校正後: 今日はとてもいい天気でした。明日もはれるといいですね。
(「はれる」は誤字ではないので残す、と判断)

スキルの実体は、手順書のテキストと、それを読み込ませる1つのツールだけ。Claude Codeのスキルも突き詰めれば「関連する作業のときにSKILL.mdを読み込ませる」仕組みなので、骨組みはこれと変わらない。複数登録しておけば、モデルがタスクに応じて使い分ける。

エラーは自分で読んで立て直す

ツールが失敗したときに何が起きるかも試す。失敗を tool ロールのメッセージとして返してやれば、モデルはそれを読んで次の手を変える。ここでは都市名をそのまま渡すと弾かれる get_weather を用意した。英語の小文字でしか引けない。

WEATHER = {"tokyo": "晴れ、24度", "osaka": "雨、21度"}  # キーは英語小文字のみ

def get_weather(city):
    if city not in WEATHER:                      # 日本語名や大文字始まりは弾く
        return (f"ERROR: '{city}' は見つからない。city は英語の小文字で指定する。"
                f"例: {', '.join(WEATHER)}")
    return WEATHER[city]

「東京と大阪の天気」を頼むと、最初は日本語名のまま叩いて両方エラー、エラー文を読んで英語小文字で取り直した。

task: 東京と大阪の天気を教えて。
[turn 0] get_weather({'city': '東京'}) -> ERROR: '東京' は見つからない。city は英語の小文字で指定する。例: tokyo, osaka
[turn 0] get_weather({'city': '大阪'}) -> ERROR: '大阪' は見つからない。city は英語の小文字で指定する。例: tokyo, osaka
[turn 1] get_weather({'city': 'tokyo'}) -> 晴れ、24度
[turn 1] get_weather({'city': 'osaka'}) -> 雨、21度
最終回答: 東京は晴れ、24度。大阪は雨、21度。

こちらは「英語小文字で」と一行も書き換えていない。エラー文字列を返しただけで、モデルが読んで引数を直している。ツールの結果は成功でも失敗でも同じルートで戻すだけでよく、失敗を握りつぶさずエラーとして返せば、モデルがそれを読んで引数を直す。

ただしこれは「正しい値が取れるまで回る」ループでもある。放っておけば往復し続けるので、ループには上限ターンを必ず入れる。

for turn in range(max_turns):   # 上限。これ以上は回らない
    ...

安全側に倒すなら、止め方より先に閉じ込め方を決める。ツールを副作用のないダミーだけにして上限で必ず止まるようにしておけば、仮に暴走しても無害なツールを数回叩いて終わる。本物のファイル操作やシェルを渡すなら、ここに実行前の権限確認やサンドボックスが要る。

履歴を溜めっぱなしにすると破綻する

ただし「捨てずに持っている」のは、長く続けると破綻する。messages は1ターンごとに入力・モデルの応答・ツール結果が積み上がる。エージェントがツールを何度も往復するほど、1回の応答のために送り直すトークンが増える。やがてコンテキストウィンドウを使い切り、そこに至るまでも会話の長さに比例して課金トークンが膨らむ。

だから最近は、全履歴をそのまま抱える代わりに記憶をどう間引くか、という方向の実装がいろいろ出ている。コンテキストの中身を管理する側では、アプローチが大きく分かれる。

アプローチやること参考記事
時間減衰忘却曲線で古い文脈の重みを下げて捨てるEbbinghaus忘却曲線で古い文脈を捨てるYourMemory
上書き意味的に近い古い情報を新しい情報で退役させる古い好みをsupersession chainsで退役させるVektor Memory
圧縮プロキシAPI手前で先読み要約とツール出力圧縮をかけるコンテキスト枯渇をプロキシで防ぐCompresr Context Gateway
フック注入セッションを跨いで必要な文脈だけ差し込むClaude Codeに動くメモリを足すCTX

どれも、毎回全部送るのをやめて「いま要る分だけ」を組み立て直す発想だ。一方で、これらは検索して棚から出す仕組みであって、経験をモデルの重みに畳み込む学習とは別物だと区別する議論もある(エージェントメモリは記憶ではなくメモという論文)。

もう一つの方向が、コンテキストの外にメモを書き出しておくやり方だ。記憶をMarkdownの正本ファイルに置いてGitで履歴管理する設計(エージェント記憶をMarkdownとGitに置くWUPHF)や、AIに読み返させる前提で出力をHTMLに逃がす整理(Claude Codeの出力をMarkdownからHTMLに逃がす)が出てきている。狙いは、コンテキストが溢れても、そのファイルを読み直せば別のセッションから再開できること。

このブログの実験記事も、最初からこのスタイルで書いている。ドラフトを先に立てて検証のたびに追記し、最後にまとめる。途中でAIのコンテキストが切れても、ドラフトを読めばどこまで進んだか分かるので、そこから続けられる。今回の自作エージェントはまだ全履歴をそのまま持つ方式なので、長く動かすならこのどれかを足すのが次の一手になる。

自前で書くかフレームワークに乗せるか

そもそも実務でゼロからこのループを書くことは、あまりない。普通はできあいのエージェントフレームワークを使う。ここまで自前で書いたのは中で何が起きているかを理解するためで、tools 配列と DISPATCH に足していけば動く、というところまでは確かめられた。

実運用に寄せると、ストリーミング出力、ツール実行前の権限確認、履歴の自動圧縮、リトライといった周辺が要る。そこは既存のツールが用意している。Qwen公式のQwen-Agentは関数呼び出しのフォールバックやMCP接続まで面倒を見るし、LangGraphは制御フローをグラフで書ける。OpenAIのAgents SDKも同じくOpenAI互換APIで動く。どれもbase_urlとAPIキーを差し替えれば、Qwenをそのまま裏に置ける。

この構造はClaude Codeのようなコーディングエージェントにも当てはまる。CLIで動いているのも同じ二重ループだ。違うのはツールがRead/Write/Bashと数十個ある点、システムプロンプトが巨大な点、上で書いたコンテキスト管理が作り込まれている点くらいで、動かしている中身は今回書いた数十行のループそのものだ。

仕様を詰めて掲示板を作らせる

最後に、ここまでのツール・会話・行動を一本につなぐ。これまではモデルに「掲示板を作って」と一言投げて一発で出させてきた。Qwen3.7 Plusのサイズなら掲示板くらい一発で書けるので、ここで試すのは生成力ではなく、数回対話して要件を固めてから作れるかのほうだ。

ファイルを書き出す write_file ツールを1つ与える。書き込み先は bbs_out/ のサンドボックスに限定し、システムプロンプトで「要件が曖昧なら作る前に質問しろ」と指示するだけ。

def write_file(filename, content):
    name = pathlib.Path(filename).name        # bbs_out/ 配下のみに限定
    (OUT / name).write_text(content, encoding="utf-8")
    return f"書き込み完了: bbs_out/{name} ({len(content)} bytes)"

「簡単な掲示板を作りたい」とだけ投げると、技術スタック・機能・デザインを聞き返してきた。

入力> 簡単な掲示板(BBS)を作りたい。
Qwen> いくつか確認させてください。
  1. 技術スタック(HTML+JS / PHP / Node / Python+Flask …)
  2. 機能(表示 / 投稿 / 削除 / スレッド / 管理者削除 …)
  3. デザイン(シンプル? モダン?)

仕様を返すと、その場で write_file を呼んで実装を書き出した。

入力> Python + Flask で1ファイル。投稿は名前・本文・日時、データはJSONに保存。
      機能は表示・新規投稿・削除。デザインは最低限。新しい投稿が上。
  [tool] write_file(app.py, 4048 bytes) -> 書き込み完了: bbs_out/app.py
Qwen> 完成しました。pip install flask して python app.py で起動できます。

出てきた app.py をそのまま起動して確認した。投稿が空の状態から、投稿すると名前・本文・日時が新しい順で並び、JSONにも保存され、削除で消える。指定した仕様どおりに動いた。

GET  /          -> 「まだ投稿がありません」
POST /post      -> 302(かな / テスト投稿)
GET  /          -> かな / 2026年06月19日 / テスト投稿
POST /delete/1  -> 302
GET  /          -> 「まだ投稿がありません」

一言で丸投げするのと違うのは、足りない仕様をモデルの側から詰めてくる点だ。ツール呼び出し・会話の記憶・行動(ファイル書き出し)・結果の確認が、一つの実用タスクの中で一通りつながった。

出てきたコードはそのまま信用しない

動いたとはいえ、出てきたコードをそのまま本番に置けるかは別だ。生成物のセキュリティを軽く突いてみた。

まずXSS。投稿本文に <script>alert('xss')</script> を入れて表示させると、&lt;script&gt; にエスケープされて素通りしなかった。ストアドXSSは通らない。

投稿した本文: <script>alert('xss')</script>
表示されたHTML: &lt;script&gt;alert('xss')&lt;/script&gt;

ただしこれはモデルがXSS対策を書いたからではない。render_template_string{{ post.body }} をJinja2がデフォルトで自動エスケープしているだけで、コードに escape() の類は入っていない。フレームワークの初期値に助けられている。

逆に、手当てされていない穴もある。生成された app.pyapp.run(debug=True, host='0.0.0.0') で起動する設定だ。デバッガが全インターフェースに開くので、そのまま公開するとそこからコードを実行されかねない。投稿フォームにCSRF対策も無い(削除に認証が無いのは「シンプルな掲示板」と指定したぶん、仕様どおり)。

エージェントは「動く、かつ出力はエスケープ済み」までは出すが、ハードニングまではやらない。頼んだ最小仕様に忠実なだけなので、出てきたコードはそのまま信用せず、自分でレビューする前提で使う。

この「どこまで勝手に手を打つか」は、Qwen3.6に一発で掲示板を作らせて意図の汲み取りを測った時に扱った軸でもある。あのときは単一HTMLで、XSSエスケープを自分で書くかどうかがモデルの差になった。今回はFlaskとJinjaの自動エスケープに乗ったぶん、その判断はフレームワーク任せになっていた。