PHP로 도구를 호출하는 AI 만들기 — Responses API Function Calling과 strict JSON Schema

조회수 16

도입

모델이 “게시글 36번을 찾아보겠습니다”라고 말하는 것과 실제 DB에서 글을 읽는 것은 다르다. Function Calling은 모델이 허용된 함수의 이름과 인자를 구조화해 요청하고, 애플리케이션이 함수를 실행한 뒤 결과를 모델에 돌려주는 방식이다.

예제는 PHP 8.1 이상과 cURL 확장을 기준으로 읽기 전용 get_post 하나만 공개한다. API 키는 서버 환경변수에 두고 브라우저로 전달하지 않는다.

전체 흐름

  1. 서버가 도구의 이름, 설명과 JSON Schema를 Responses API에 보낸다.
  2. 모델이 function_call 항목으로 도구 호출을 요청한다.
  3. PHP가 이름과 인자를 다시 검증하고 서버 함수를 실행한다.
  4. 같은 call_id를 가진 function_call_output을 전송한다.
  5. 모델이 도구 결과를 바탕으로 최종 답변을 만든다.

모델은 실행 권한을 갖지 않는다. 실행 여부를 결정하는 주체는 항상 서버 코드다.

cURL 공통 함수

<?php
declare(strict_types=1);

function openaiResponse(array $payload): array
{
    $apiKey = getenv('OPENAI_API_KEY');
    if (!$apiKey) {
        throw new RuntimeException('OPENAI_API_KEY is not configured');
    }

    $ch = curl_init('https://api.openai.com/v1/responses');
    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CONNECTTIMEOUT => 5,
        CURLOPT_TIMEOUT => 30,
        CURLOPT_HTTPHEADER => [
            'Authorization: Bearer ' . $apiKey,
            'Content-Type: application/json',
        ],
        CURLOPT_POSTFIELDS => json_encode(
            $payload,
            JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR
        ),
    ]);

    $body = curl_exec($ch);
    if ($body === false) {
        throw new RuntimeException(curl_error($ch));
    }

    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);

    $data = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
    if ($status < 200 || $status >= 300) {
        throw new RuntimeException('OpenAI API error: HTTP ' . $status);
    }

    return $data;
}

운영 로그에는 Authorization 헤더, 전체 사용자 입력, 민감한 도구 결과를 그대로 남기지 않는다.

strict 도구 선언

모델 ID는 계정에서 사용 가능한 현재 모델을 환경설정으로 관리한다.

$tools = [[
    'type' => 'function',
    'name' => 'get_post',
    'description' => '공개된 개발 블로그 게시글 한 건을 번호로 조회한다.',
    'strict' => true,
    'parameters' => [
        'type' => 'object',
        'properties' => [
            'post_id' => [
                'type' => 'integer',
                'description' => '조회할 게시글 번호',
                'minimum' => 1,
            ],
        ],
        'required' => ['post_id'],
        'additionalProperties' => false,
    ],
]];

$model = getenv('OPENAI_MODEL') ?: 'gpt-5.6';

$first = openaiResponse([
    'model' => $model,
    'input' => '개발 블로그 36번 글의 핵심을 알려줘.',
    'tools' => $tools,
]);

strict: true, 모든 필드의 required, additionalProperties: false를 함께 사용해 인자 형태를 좁힌다. 그렇더라도 서버 검증은 생략할 수 없다.

허용 목록으로 실행

실제 구현에서는 prepared statement와 view_flag, delete_flag, 블로그 범위를 적용한다.

function getPost(int $postId): array
{
    if ($postId < 1 || $postId > 1_000_000) {
        throw new InvalidArgumentException('invalid post_id');
    }

    // 예제 결과. 실제로는 읽기 전용 DB 계정과 prepared statement를 사용한다.
    return [
        'found' => true,
        'post_id' => $postId,
        'title' => '트랜잭션 개념 정리와 실무에서 주의할 점',
        'summary' => '트랜잭션 경계와 COMMIT, ROLLBACK의 실무 기준',
    ];
}

$toolOutputs = [];

foreach ($first['output'] ?? [] as $item) {
    if (($item['type'] ?? '') !== 'function_call') {
        continue;
    }

    $name = $item['name'] ?? '';
    if ($name !== 'get_post') {
        throw new RuntimeException('tool is not allowed');
    }

    $args = json_decode(
        $item['arguments'] ?? '{}',
        true,
        512,
        JSON_THROW_ON_ERROR
    );

    if (!isset($args['post_id']) || !is_int($args['post_id'])) {
        throw new InvalidArgumentException('post_id must be an integer');
    }

    try {
        $result = getPost($args['post_id']);
    } catch (Throwable $e) {
        $result = ['found' => false, 'error' => 'lookup_failed'];
    }

    $toolOutputs[] = [
        'type' => 'function_call_output',
        'call_id' => $item['call_id'],
        'output' => json_encode(
            $result,
            JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR
        ),
    ];
}

함수 이름을 받아 call_user_func()로 임의 실행하면 안 된다. 명시적인 허용 목록으로 분기하고, 도구 결과도 외부 입력처럼 취급한다.

결과를 돌려주고 최종 답변 받기

if ($toolOutputs === []) {
    throw new RuntimeException('expected tool call was not returned');
}

$nextInput = array_merge($first['output'] ?? [], $toolOutputs);

$final = openaiResponse([
    'model' => $model,
    'input' => $nextInput,
    'tools' => $tools,
]);

function extractOutputText(array $response): string
{
    $parts = [];
    foreach ($response['output'] ?? [] as $item) {
        if (($item['type'] ?? '') !== 'message') {
            continue;
        }
        foreach ($item['content'] ?? [] as $content) {
            if (($content['type'] ?? '') === 'output_text') {
                $parts[] = (string)($content['text'] ?? '');
            }
        }
    }
    return trim(implode("\n", $parts));
}

echo htmlspecialchars(
    extractOutputText($final) ?: '답변을 생성하지 못했습니다.',
    ENT_QUOTES | ENT_SUBSTITUTE,
    'UTF-8'
);

첫 응답의 output 전체를 도구 결과와 함께 다음 입력에 포함했다. 추론 모델이 도구 호출과 함께 반환한 reasoning 항목도 보존해야 하기 때문이다. 각 결과는 모델이 보낸 call_id에 정확히 대응한다. 한 응답에 여러 호출이 올 수 있으므로 첫 항목만 처리하지 않는다.

이 글의 기본 모델명은 확인일 현재 공식 Function Calling 예제에 맞췄다. 사용 가능한 모델은 프로젝트와 계정에 따라 다를 수 있으므로 OPENAI_MODEL 설정으로 교체하고, 배포 전 공식 모델 문서와 API 응답 구조를 다시 확인한다.

위협 모델

  • 사용자 입력이 도구 설명을 무시하라고 해도 서버 허용 목록은 변하지 않아야 한다.
  • 게시글 ID로 SQL 문자열을 조합하지 않고 prepared statement를 사용한다.
  • 비공개·삭제 글은 도구 함수에서 제외한다.
  • 도구 출력에 HTML이 있으면 화면 출력 전에 escape 또는 allowlist 정제를 적용한다.
  • 쓰기 도구는 읽기 도구와 분리하고 인증·승인·멱등성·감사 로그를 추가한다.
  • 호출 횟수, 실행 시간과 출력 크기에 상한을 둔다.

점검 체크리스트

  • API 키가 서버 환경변수 또는 비밀 저장소에 있는가?
  • 도구 schema가 strict하고 추가 속성을 거부하는가?
  • 서버가 이름과 인자를 독립적으로 검증하는가?
  • call_id가 정확히 대응되는가?
  • 여러 도구 호출과 도구 오류를 처리하는가?
  • DB 계정이 읽기 전용이며 공개 데이터만 반환하는가?
  • 모델 출력과 도구 출력이 안전하게 렌더링되는가?

함께 읽기

참고 자료

확인일: 2026-07-29

한 줄 요약

Function Calling의 안전성은 모델의 판단이 아니라 strict schema, 서버 허용 목록, 인자 재검증과 최소 권한 도구 구현에서 나온다.

댓글 0

  • 첫 번째 댓글을 남겨보세요.