2025, Nov 09 21:02

Как продолжить незавершённый вызов инструмента в LLM: префиксная допись на Hugging Face Transformers

Разбираем, как дописать незавершённый вызов инструмента в LLM без сброса по EOS: префиксное продолжение внутри реплики, пример на Hugging Face Transformers

Вызов инструментов с помощью LLM обычно опирается на простой двухшаговый сценарий: сначала перечислить варианты, затем выполнить один из них. На практике небольшие модели нередко «зависают» между этими шагами: останавливаются после списка файлов и так и не делают следующий вызов, который действительно открывает документ. Отсюда логичный вопрос: можно ли заставить модель продолжить незавершённую реплику ассистента — дописать начатое на странице, а не собирать ответ заново?

Минимальный пример

Представьте поток «файлового проводника», где ассистент сперва перечисляет файлы, а затем вызывает функцию, чтобы открыть конкретный. В этот момент разрешён только второй вызов инструмента, так что продолжение должно быть очевидным, но модель порой останавливается, не доведя его до конца.

Input:
"""
User: Open the about file.
Assistant: *list_dir()
Tool: Available files, pleases select one:
 - file.txt
 - example.txt
 - about.txt
 - random.txt
Assistant: *read_file("
"""
LLM Output:
"""
about.txt")
"""

Желаемое поведение — простая автодопись в рамках той же реплики ассистента.

Что на самом деле ломается

Ответ генерируется токен за токеном, но многие чат-API завершают сообщение ассистента, как только встречают маркер конца последовательности. После этого следующая генерация идёт уже новой репликой, а не продолжением текущей. Если платформа не поддерживает продолжение от префикса внутри текущего сообщения ассистента, нельзя надёжно подтолкнуть модель «подхватить на середине вызова» и дописать уже начатые аргументы функции.

Иными словами, нужна допись по префиксу внутри самого сообщения ассистента. Когда это недоступно, второй вызов инструмента часто пропадает.

Практический обходной путь с Transformers

Когда API не предоставляет такую возможность, можно приблизить её, собрав чат-промпт вручную и удалив токен EOS, чтобы модель продолжила с заданного префикса. Ниже — компактный демонстрационный пример на Hugging Face Transformers и PyTorch. Он формирует вход чата, убирает финальный EOS, а затем шагает токен за токеном, позволяя модели дописать аргументы для незавершённого вызова инструмента.

from transformers import AutoTokenizer, AutoModelForCausalLM
import torch
runtime_device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(runtime_device)
repo_id = 'Qwen/Qwen3-0.6B'
tok = AutoTokenizer.from_pretrained(repo_id)
lm = AutoModelForCausalLM.from_pretrained(repo_id).to(runtime_device)
def fetch_file(filename: str) -> str:
    """
    Открывает текстовый документ с именем "filename".
    Пример вызова:
    fetch_file("example.txt")
    Аргументы:
        filename: имя файла документа
    Возвращает:
        str: содержимое файла
    """
    return f"Succesfully opened file \"{filename}\"."
toolset = [fetch_file]
directory_view = """
File Explorer
Available files, use the "*open_file()*" function to open only one:
 - about.txt
 - coding_paper.txt
 - system_requirements.txt
 - updates.txt"""
chat_log = [
    {'role': 'user', 'content': "What's your latest update?"},
    {'role': 'tool', 'content': directory_view},
    {'role': 'assistant', 'content': '<tool_call>\n{"name": "fetch_file", "arguments": {"filename": "'}
]
TOPK = 5
MAX_TOKENS_LIMIT = 32768
def continue_span(dialogue):
    seq_ids = tok.apply_chat_template(
        dialogue,
        tools=toolset,
        return_tensors='pt',
        padding=True,
        truncation=True
    ).to(runtime_device)[:, :-2]  # убрать токен EOS
    print(tok.decode(seq_ids[0], skip_special_tokens=False), end='', flush=True)
    while True:
        piece, seq_ids, is_eos = next_symbol(seq_ids)
        if is_eos:
            break
        print(piece, end='', flush=True)
def next_symbol(seq_ids):
    with torch.no_grad():
        out = lm(seq_ids)
    logits = out.logits
    last_step = logits[0, -1, :]
    probs = torch.softmax(last_step, dim=-1)
    topk_probs, topk_indices = torch.topk(probs, TOPK)
    best_id = topk_indices[0].reshape(1,)
    seq_ids = torch.cat([seq_ids, best_id.unsqueeze(0)], dim=-1)
    piece = tok.decode([best_id.item()])
    is_eos = False
    if best_id == tok.eos_token_id:
        is_eos = True
    return piece, seq_ids, is_eos
continue_span(chat_log)

Этот фрагмент завершает аргументы вызова инструмента, продолжая частичный вывод ассистента. Ключевой момент — из собранного представления чата удаляется конечный токен, благодаря чему модель воспринимает незакрытый вызов как продолжающуюся последовательность, а не как завершённую реплику.

Почему это важно

Продолжение внутри реплики улучшает единообразие форматирования и снижает риск нестабильного разбора вокруг «каркаса» вызовов инструментов. Когда модель раз за разом не выдаёт второй вызов, возможность возобновить генерацию из середины сообщения сохраняет структуру и избавляет от трюков с перепромптингом, которые всё равно не гарантируют нужный формат.

Выводы

Если нужно принудительно выполнить последующий вызов инструмента из частично написанной реплики, а ваш чат-рантайм этого не умеет, временной мерой может быть самостоятельная сборка промпта и продолжение генерации после пропущенного сегмента. Подход выше — лишь демонстрация того, как дописать вызов инструмента; это не фреймворк для его исполнения. В продакшене следите за возможностями платформы и используйте встроенные функции вроде принудительных вызовов инструментов или инструментов с выбором из нескольких опций по мере их появления.

Статья основана на вопросе с сайта StackOverflow от автора Bob и ответе автора Bob.