도입
모델이 “게시글 36번을 찾아보겠습니다”라고 말하는 것과 실제 DB에서 글을 읽는 것은 다르다. Function Calling은 모델이 허용된 함수의 이름과 인자를 구조화해 요청하고, 애플리케이션이 함수를 실행한 뒤 결과를 모델에 돌려주는 방식이다.
예제는 PHP 8.1 이상과 cURL 확장을 기준으로 읽기 전용 get_post 하나만 공개한다. API 키는 서버 환경변수에 두고 브라우저로 전달하지 않는다.
전체 흐름
- 서버가 도구의 이름, 설명과 JSON Schema를 Responses API에 보낸다.
- 모델이
function_call항목으로 도구 호출을 요청한다. - PHP가 이름과 인자를 다시 검증하고 서버 함수를 실행한다.
- 같은
call_id를 가진function_call_output을 전송한다. - 모델이 도구 결과를 바탕으로 최종 답변을 만든다.
모델은 실행 권한을 갖지 않는다. 실행 여부를 결정하는 주체는 항상 서버 코드다.
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