CAPのサービスをAIエージェントにして、いろいろ動かしてきました。ところが、いざ人に説明しようとすると「LLMがCAPを呼んでいるのか、CAPがLLMを呼んでいるのか」「関数を実行しているのはどっちなのか」が、自分でもあいまいなことに気づきました。
そこで、特定のアプリからいったん離れて、CAPとLLMの関係を「誰が誰を呼ぶか」という視点で整理し直しました。図と短いコードで説明します。
2026年10月25日時点の理解をまとめたものです。コードは説明用に短くしたもので、そのままコピーして動くものではありません
まとめ
| 項目 | 内容 |
|---|---|
| 一番大事なこと | CAPがLLMを呼ぶ。LLMはCAPを呼ばない。 LLMはCAPから見ると「HTTPで呼ぶ外部APIの1つ」 |
| LLMが返すもの | 「この関数を、この引数で呼んで」という文字(JSON)だけ。関数を実行するのはプログラム側 |
| 関係のパターン | A:CAPがLLMを関数として使う/B:CAPがエージェントになる/C:外のAIアシスタントがCAPを使う |
| 役割分担 | LLMは「賢さ」(読み取り・判断・文章化)、CAPは「確実さ」(データ・実行・チェック・記録・権限) |
1. CAPとLLMはそれぞれ何者か
| CAP | LLM | |
|---|---|---|
| 正体 | 業務アプリを作るためのフレームワーク(Node.js/Java) | 文章を送ると文章を返す外部のAPI(OpenAI、Claude、Geminiなど) |
| 持っているもの | データ(DB)、業務ルール、API(ODataなど) | 言葉の知識(学習済みのモデル) |
| 持っていないもの | 日本語を「理解」する力 | 自社のデータ、業務ルール、会話の記憶 |
| 呼ばれ方 | HTTPで呼ばれる(/odata/v4/...) |
HTTPで呼ばれる(https://api.openai.com/...) |
ポイントは、 どちらも「HTTPで呼ばれる側」 だということです。LLMが自分から誰かを呼びに行くことはありません。
CAPから見ると、LLMは 天気APIや郵便番号APIと同じ「外部APIの1つ」 です。違うのは、入力と出力が自然な文章だということだけです。
2. LLMの呼び出しは、ただのHTTP
LLMを呼ぶ最小のプログラムです。
const res = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${process.env.OPENAI_API_KEY}` },
body: JSON.stringify({
model: 'gpt-4o-mini',
messages: [{ role: 'user', content: '「発注」を英語で一言で' }],
}),
})
const data = await res.json()
console.log(data.choices[0].message.content) // → "Purchase order"
ここで押さえておきたいことは2つです。
- 会話の中身は、
bodyの中のmessagesに入れて送ります。この例では、利用者の発言1件だけです。LLMは前の呼び出しを覚えていないので、続きの会話をしたいときは、呼ぶ側がこの配列に前のやり取りを全部入れて送り直します。
// 2回目の呼び出し:1回目のやり取りも入れて送り直す
messages: [
{ role: 'user', content: '「発注」を英語で一言で' },
{ role: 'assistant', content: 'Purchase order' }, // 1回目のLLMの答え
{ role: 'user', content: 'じゃあ「仕入先」は?' }, // 今回の質問
],
- 返ってくるのはJSONです。中の文字列を取り出すのは呼ぶ側の仕事です。
3. CAPとLLMの関係は3パターン
先に、この記事での「エージェント」の意味をそろえておきます。
この記事で「エージェント」と呼ぶのは、LLMに「次は何をする?」と聞き、回答された関数を自分で実行し、結果を渡してまた聞く、を繰り返すプログラムのことです。
LLMそのものはエージェントではありません。LLMは、エージェントが相談する相手(頭脳役)です。
整理してみると、CAPとLLMの関係は次の3つに分けられました。違いは、エージェントがいるかどうか、いるならCAPの中か外かです。
| パターン | 概要 | 誰が誰を呼ぶか |
|---|---|---|
| A. CAPがLLMを「便利な関数」として使う | 要約・分類・翻訳など。エージェントはいない | CAP → LLM |
| B. CAPがエージェントになる | エージェントはCAPの中。LLMに「次に何をするか」を考えてもらい、CAPが実行する | CAP → LLM(何回も)、実行はCAP |
| C. 外のAIアシスタントがCAPを道具として使う | エージェントはCAPの外(ClaudeやJouleなどのAIアシスタント)。CAPは道具を提供する側 | AIアシスタントのプログラム → CAP |
どのパターンでも、LLMから出ている矢印はありません。 LLMはいつも「呼ばれて、文字を返す」だけです。以下、順に見ていきます。
4. パターンA:CAPがLLMを「便利な関数」として使う
一番単純な使い方です。CAPのハンドラの中で、外部APIとしてLLMを1回呼びます。問い合わせの本文を要約する例です。
service InquiryService {
entity Inquiries { key ID : UUID; body : String; summary : String; }
action summarize(ID : UUID) returns String;
}
import cds from '@sap/cds'
export default class InquiryService extends cds.ApplicationService {
init() {
const { Inquiries } = this.entities
this.on('summarize', async (req) => {
const { body } = await SELECT.one.from(Inquiries, req.data.ID) // ① DBを読むのはCAP
const summary = await askLLM([ // ② LLMには文字だけ渡す
{ role: 'system', content: '問い合わせを100字以内で要約してください' },
{ role: 'user', content: body },
])
await UPDATE(Inquiries, req.data.ID).with({ summary }) // ③ 保存するのもCAP
return summary
})
return super.init()
}
}
async function askLLM(messages) {
const res = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${process.env.OPENAI_API_KEY}` },
body: JSON.stringify({ model: 'gpt-4o-mini', messages }),
})
return (await res.json()).choices[0].message.content
}
- LLMはDBを見ていません。CAPが読んで、文字として渡しています。
- やっていることは「外部APIを呼んで、結果を保存する」という、普通のCAPの処理です。
5. パターンB:CAPがエージェントになる(ツール呼び出し)
「テスト文具にノートを5冊発注して」のような依頼を、LLMに手順を考えてもらいながら処理するパターンです。これまで @cap-js/agents を使用していたのはこれでした。
仕組みの核は**ツール呼び出し(Function Calling/Tool Calling)**です。
- CAPがLLMに、使える関数の 一覧(名前・説明・引数の形) を渡します。関数の中身(コード)は渡しません。
- LLMは「
searchSuppliersを{"keyword":"テスト文具"}で呼んで」という**文字(JSON)**を返します。 - CAPがその名前の関数を実行し、結果をLLMに渡して、また聞きます。
- LLMが関数を頼まなくなったら、それが最後の返事です。
この例では、ループが3回まわります(① 仕入先の検索を頼まれる → ② 発注の登録を頼まれる → ③ 最後の返事)。ツールを1回使うたびに、LLMとの往復が1回増えます。
最小のプログラム
// ① 関数の本体(CAPならサービスのアクションのハンドラ)
const tools = {
searchSuppliers: ({ keyword }) => suppliers.filter((s) => s.name.includes(keyword)),
createOrder: ({ supplierCode, quantity, unitPrice }) => {
if (quantity <= 0) throw new Error('数量は1以上にしてください') // 業務ルールはプログラム側
return { orderNo: 4500000001, supplierCode, total: quantity * unitPrice }
},
}
// ② LLMに渡す「関数の一覧」(説明書だけ。コードは渡さない)
// CAPなら、CDSのアクション定義と /** */ コメントから作れる
const toolDefinitions = [
{ type: 'function', function: { name: 'searchSuppliers', description: '仕入先を名前の一部で検索する',
parameters: { type: 'object', properties: { keyword: { type: 'string' } }, required: ['keyword'] } } },
{ type: 'function', function: { name: 'createOrder', description: '発注を登録する',
parameters: { type: 'object', properties: { supplierCode: { type: 'string' }, quantity: { type: 'integer' }, unitPrice: { type: 'number' } },
required: ['supplierCode', 'quantity', 'unitPrice'] } } },
]
// ③ ループ(これが「エージェント」の正体)
const messages = [{ role: 'user', content: 'テスト文具にノートを5冊、単価200円で発注して' }]
while (true) {
const answer = await askLLM(messages, toolDefinitions) // LLMに聞く(HTTP POST)
messages.push(answer)
if (!answer.tool_calls) { console.log(answer.content); break } // 最後の返事
for (const call of answer.tool_calls) {
const name = call.function.name // ただの文字列
const args = JSON.parse(call.function.arguments) // ただの文字列 → オブジェクト
let result
try {
result = tools[name](args) // ④ ★ 実行するのはこのプログラム
} catch (e) {
result = { error: e.message } // エラーも文字にしてLLMに返す
}
messages.push({ role: 'tool', tool_call_id: call.id, content: JSON.stringify(result) })
}
}
tools[name](args) の1行が全体の核です。 LLMが返した文字列で関数の表を引き、プログラムが呼びます。Webアプリのルーティング(URLの文字列 /orders を見て createOrder() を呼ぶ)と同じ形です。
CAPでこれをやると、関数を実行する tools[name](args) の行(コメントの④)が、srv.send(name, args)(別のサービスのアクションを呼ぶ、いつもの書き方)になります。画面から呼ばれたときと同じハンドラを通るので、LLM経由でも入力チェックをすり抜けません。
実際に動かすと
同じ形のループを、プラグインを使わずにCAPのサービスとして手で書き、動かしてみました。ログは次のとおりです。
[agent] --- 1 回目: CAP → LLM (メッセージ 2 件 + ツール一覧 2 件を送る)
[agent] LLM → 「searchSuppliers を {"keyword":"テスト文具"} で呼んで」
[agent] CAP が実行 → 結果: [{"code":"100003","name":"テスト文具株式会社"}]
[agent] --- 2 回目: CAP → LLM (メッセージ 4 件 + ツール一覧 2 件を送る)
[agent] LLM → 「createOrder を {"supplierCode":"100003","itemText":"ノート","quantity":5,"unitPrice":200} で呼んで」
[agent] CAP が実行 → 結果: {"orderNo":4500000001,...,"total":1000}
[agent] --- 3 回目: CAP → LLM (メッセージ 6 件 + ツール一覧 2 件を送る)
[agent] LLM → 返事: 発注しました。発注番号は 4500000001、合計 1000 円です。
- 送るメッセージが2 → 4 → 6件と増えています。毎回、会話全部を送り直していることがわかりました。
- 仕入先コード
100003は、LLMがもともと知っていたものではありません。CAPがDBを検索して、結果を文字で渡したからわかったものです。 - 数量を0冊にして頼むと、LLMは0のまま
createOrderを呼ぼうとしました。止めたのはCAPのハンドラの入力チェックです。業務ルールはプロンプトではなく、プログラムに書く必要があります。
@cap-js/agents の @agent は、この最小のプログラムでやっていること(関数の一覧づくり、LLMの呼び出し、ループ、関数の実行)をCAPのサービスから自動で用意してくれるものだと理解しました。
6. パターンC:外のAIアシスタントがCAPを道具として使う
Claude DesktopやJouleなど、CAPの外にあるAIアシスタントから「今月の発注を一覧にして」と聞くケースです。このAIアシスタントも、LLMに聞きながら関数を実行していくプログラムなので、エージェントです。CAPは、そのエージェントに「ツールを提供する側」になります。
パターンBと見比べると、エージェントがCAPの中から外に移っただけです。Bではエージェント=CAP、CではエージェントがAIアシスタントで、CAPはその道具になっています。
- 一見「LLMがCAPを呼んでいる」ように見えますが、CAPにHTTPを送っているのはAIアシスタントのプログラムです。LLMは相変わらず「呼んで」という文字を返しているだけです。
- CAPから見ると、普通のAPIの利用者が1人増えただけです。認証・権限・入力チェックは、画面から呼ばれたときと同じようにCAPが行います。
AIアシスタント側のプログラムで、ツールを実行する部分のイメージです。
// AIアシスタント側のプログラム(CAPの外)
const tools = {
getOrders: async ({ month }) => {
const res = await fetch(`https://my-cap-app.example.com/odata/v4/order/Orders?$filter=startswith(orderDate,'${month}')`, {
headers: { Authorization: `Bearer ${token}` }, // CAPの認証はそのまま効く
})
return (await res.json()).value
},
}
// あとはパターンBと同じループ:tools[name](args)
7. 役割分担
3つのパターンを通して見ると、役割分担はいつも同じでした。
自分が誤解していたことも含めて、表にしておきます。
| よくある誤解 | 正しくは |
|---|---|
| LLMがDBを読んでいる | 読むのはプログラム(CAP)。LLMは結果の文字を受け取るだけ |
| LLMが関数を実行している | LLMは「この関数を呼んで」というJSONを返すだけ。実行はプログラム |
| LLMは会話を覚えている | 覚えていない。プログラムが毎回、会話全部を送り直す |
| 業務ルールはプロンプトに書けば守られる | プロンプトは「お願い」。守らないことがあるので、必ずプログラムでチェックする |
| AI用に別のシステムが要る | パターンA・BはCAPのサービスに処理を足すだけ。Cも既存のAPIをそのまま使える |
感想
- ざっくりというと、LLMは「考えて、文字で答える」API、CAPは「データを持ち、ルールを守って、実際に処理する」アプリでした。
-
@cap-js/agentsを使うと@agentを付けるだけでエージェントになるので、便利な反面、中で何が起きているかが見えにくくなっていました。 - 「LLMは関数を実行しない」「業務ルールはプログラム側」とわかると、HITL(人の確認)や入力チェックをどこに置くべきかも自然に決まると感じました。