Files
liqiang b119135836
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
2026-08-20 13:12:50 +00:00

446 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Data generation script for prompt distillation using vLLM.
This script generates training data for prompt distillation by using a teacher model
to generate language classification labels with a detailed prompt, which will then be
used to train a student model that internalizes the prompt.
Based on the tinker cookbook prompt distillation recipe.
"""
import argparse
import asyncio
import json
import os
import re
from pathlib import Path
from typing import Optional
from tqdm.asyncio import tqdm_asyncio
# 注意:vllm / SamplingParams 在 generate_distillation_data() 内部按需导入,
# 这样即便未安装 vllm(如离线查看 --help 时)也能正常展示命令行帮助。
LANGUAGE_CLASSIFICATION_PROMPT = """You are a precise language classifier.
Goal: Classify the language of the provided text into exactly one of these labels:
ar (Arabic), de (German), el (Greek), en (English), es (Spanish), fr (French),
hi (Hindi), ru (Russian), tr (Turkish), ur (Urdu), vi (Vietnamese),
zh (Chinese - Simplified), ot (Other/Unknown).
Instructions:
1) Preprocess carefully (without changing the intended meaning):
- Trim whitespace.
- Ignore URLs, emails, file paths, hashtags, user handles, and emojis.
- Ignore numbers, math expressions, and standalone punctuation.
- If there is code, IGNORE code syntax (keywords, operators, braces) and focus ONLY on human language in comments and string literals.
- Preserve letters and diacritics; do NOT strip accents.
- If after ignoring the above there are no alphabetic letters left, output 'ot'.
2) Script-based rules (highest priority):
- Devanagari script → hi.
- Greek script → el.
- Cyrillic script → ru.
- Han characters (中文) → zh. (Treat Traditional as zh too.)
- Arabic script → ar vs ur:
• If Urdu-only letters appear (e.g., ے, ڑ, ں, ھ, ٹ, ڈ, کھ, گ, چ with Urdu forms), or clear Urdu words, choose ur.
• Otherwise choose ar.
(If multiple scripts appear, pick the script that contributes the majority of alphabetic characters. If tied, go to step 5.)
3) Latin-script heuristics (use when text is mainly Latin letters):
- vi: presence of Vietnamese-specific letters/diacritics (ă â ê ô ơ ư đ, plus dense diacritics across many words).
- tr: presence of Turkish-specific letters (ı İ ğ Ğ ş Ş ç Ç ö Ö ü Ü) and common function words (ve, bir, için, değil, ama, çok).
- de: presence of umlauts (ä ö ü) or ß and common function words (und, der, die, das, nicht, ist).
- es: presence of ñ, ¿, ¡ and common words (y, de, la, el, es, no, por, para, con, gracias, hola).
- fr: frequent French diacritics (é è ê à ç ô â î û ù) and common words (et, le, la, les, des, une, est, avec, pour, merci, bonjour).
- en: default among Latin languages if strong evidence for others is absent, but ONLY if English function words are present (the, and, is, are, to, of, in, for, on, with). If evidence is insufficient for any Latin language, prefer 'ot' over guessing.
4) Named entities & loanwords:
- Do NOT decide based on a single proper noun, brand, or place name.
- Require at least two function words or repeated language-specific signals (diacritics/letters) before assigning a Latin-language label.
5) Mixed-language text:
- Determine the dominant language by counting indicative tokens (language-specific letters/diacritics/function words) AFTER preprocessing.
- If two or more languages are equally dominant or the text is a deliberate multi-language mix, return 'ot'.
6) Very short or noisy inputs:
- If the text is ≤2 meaningful words or too short to be confident, return 'ot' unless there is a very strong language-specific signal (e.g., "bonjour" → fr, "hola" → es).
7) Transliteration/romanization:
- If Hindi/Urdu/Arabic/Chinese/Russian/Greek is written purely in Latin letters (romanized) without clear, repeated language-specific cue words, return 'ot'. (Only classify as hi/ur/ar/zh/ru/el when native scripts or highly distinctive romanized patterns are clearly present.)
8) Code-heavy inputs:
- If the text is mostly code with minimal or no natural-language comments/strings, return 'ot'.
- If comments/strings clearly indicate a language per rules above, use that label.
9) Ambiguity & confidence:
- When in doubt, choose 'ot' rather than guessing.
Text to classify:
{text}
Output format:
- Respond with EXACTLY one line: "Final Answer: xx"
- Where xx ∈ {{ar, de, el, en, es, fr, hi, ru, tr, ur, vi, zh, ot}} and nothing else.
"""
def parse_final_answer(response: str, debug: bool = False) -> Optional[str]:
"""
Parse the final answer from the model response.
For Thinking models, extract from <think>...</think> tags or after them.
"""
# For Thinking models, the response may have <think></think> tags
# Remove thinking content and focus on the final answer
response_stripped = response.strip()
# Remove <think>...</think> content if present
response_cleaned = re.sub(r'<think>.*?</think>', '', response_stripped, flags=re.DOTALL)
response_cleaned = response_cleaned.strip()
# Also try the original response
candidates = [response_cleaned, response_stripped]
valid_labels = {'ar', 'de', 'el', 'en', 'es', 'fr', 'hi', 'ru', 'tr', 'ur', 'vi', 'zh', 'ot'}
# Try multiple patterns to extract language label
patterns = [
r"Final Answer:\s*(\w{2})", # Standard format
r"Final Answer:\s*([a-z]{2})", # Lowercase only
r"Answer:\s*(\w{2})", # Without "Final"
r"Language:\s*(\w{2})", # "Language: xx"
r"^([a-z]{2})$", # Just the label alone
r"\b([a-z]{2})\b\s*$", # Label at the end with word boundary
r"is:\s*(\w{2})", # "is: xx"
r"→\s*(\w{2})", # "→ xx"
]
for candidate in candidates:
candidate_lower = candidate.lower()
# Try each pattern
for pattern in patterns:
match = re.search(pattern, candidate_lower, re.MULTILINE)
if match:
label = match.group(1)
if label in valid_labels:
if debug:
print(f" [DEBUG] Matched pattern '{pattern}' -> '{label}'")
return label
# Special case: check if the entire response is just a language code
if len(candidate) <= 3 and candidate_lower in valid_labels:
if debug:
print(f" [DEBUG] Matched entire response as label -> '{candidate_lower}'")
return candidate_lower
if debug:
print(f" [DEBUG] No pattern matched.")
print(f" [DEBUG] Response length: {len(response_stripped)}")
print(f" [DEBUG] Cleaned response: '{response_cleaned[:300]}'")
print(f" [DEBUG] Original response: '{response_stripped[:300]}'")
return None
async def generate_distillation_data(
input_file: str,
output_file: str,
model_name: str = "Qwen/Qwen3-30B-A3B-Thinking-2507",
temperature: float = 0.15,
max_tokens: int = 4096,
tensor_parallel_size: int = 1,
max_retries: int = 3,
):
"""
Generate prompt distillation training data.
Args:
input_file: Path to file containing sentences to classify (one per line)
output_file: Path to save the generated training data (JSONL format)
model_name: Teacher model to use for generating labels
temperature: Sampling temperature
max_tokens: Maximum tokens to generate
tensor_parallel_size: Number of GPUs to use for tensor parallelism
"""
print(f"Loading input sentences from {input_file}")
with open(input_file, "r", encoding="utf-8") as f:
sentences = [line.strip() for line in f if line.strip()]
print(f"Loaded {len(sentences)} sentences")
if not sentences:
print("Input file has no sentences to process, skipping data generation.")
return
from vllm import LLM, SamplingParams
# Initialize vLLM model
print(f"Initializing teacher model: {model_name}")
print(f"Using tensor parallelism across {tensor_parallel_size} GPU(s)")
# Get tokenizer to use proper chat template
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True)
llm = LLM(
model=model_name,
tensor_parallel_size=tensor_parallel_size,
trust_remote_code=True,
gpu_memory_utilization=0.90, # Use 90% of GPU memory for better throughput
max_model_len=32768, # Match training max length
enable_prefix_caching=True, # Cache the system prompt
)
# Set sampling parameters - use Qwen3 recommended settings
# For Thinking models, we need to allow enough tokens for reasoning
sampling_params = SamplingParams(
temperature=temperature,
max_tokens=max_tokens,
top_p=0.8,
top_k=20,
# Don't use custom stop sequences - let model finish naturally
skip_special_tokens=False, # Keep special tokens for thinking models
)
# Initial generation
print("Generating labels with teacher model...")
results = {} # sentence -> (response, final_answer)
failed_indices = []
failed_examples = [] # Store examples for debugging
# Format prompts using proper chat template
print("Formatting prompts with Qwen3 chat template...")
formatted_prompts = []
for sentence in sentences:
messages = [
{
"role": "user",
"content": LANGUAGE_CLASSIFICATION_PROMPT.format(text=sentence)
}
]
# Use tokenizer's chat template
prompt_text = tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True
)
formatted_prompts.append(prompt_text)
print(f"Sample formatted prompt:")
print(formatted_prompts[0])
outputs = llm.generate(formatted_prompts, sampling_params)
for idx, (sentence, output) in enumerate(zip(sentences, outputs)):
response = output.outputs[0].text
# Enable debug mode for first few failures
debug_mode = len(failed_examples) < 3
final_answer = parse_final_answer(response, debug=debug_mode)
if final_answer:
results[sentence] = (response, final_answer)
else:
failed_indices.append(idx)
# Store first 10 failed examples for debugging
if len(failed_examples) < 10:
failed_examples.append({
'sentence': sentence,
'response': response,
})
# results is keyed by sentence text, so len(results) counts UNIQUE
# sentences; the JSONL below writes one row per sentence occurrence. Count
# rows actually labeled so the reported rate matches the output when the
# corpus contains duplicate lines (common in language-ID data).
num_labeled = sum(1 for s in sentences if s in results)
print(f"\nInitial generation: {num_labeled}/{len(sentences)} successful ({num_labeled/len(sentences)*100:.2f}%)")
# Show debugging info for failed samples
if failed_examples:
print(f"\n{'='*60}")
print("DEBUGGING: Examples of FAILED responses")
print(f"{'='*60}")
for i, example in enumerate(failed_examples, 1):
print(f"\nFailed Example {i}:")
print(f" Input: {example['sentence']}")
print(f" Response: {example['response']}")
print(f" Parsed result: None")
# Show examples of successful responses
if results:
print(f"\n{'='*60}")
print("DEBUGGING: Examples of SUCCESSFUL responses")
print(f"{'='*60}")
success_examples = list(results.items())[:3]
for i, (sentence, (response, label)) in enumerate(success_examples, 1):
print(f"\nSuccess Example {i}:")
print(f" Input: {sentence}")
print(f" Response: {response}")
print(f" Parsed label: {label}")
# Retry failed generations up to max_retries times
for retry in range(1, max_retries + 1):
if not failed_indices:
break
print(f"\nRetry {retry}/{max_retries}: Regenerating {len(failed_indices)} failed samples...")
# Prepare prompts for failed sentences
retry_sentences = [sentences[idx] for idx in failed_indices]
retry_formatted_prompts = []
for s in retry_sentences:
messages = [
{
"role": "user",
"content": LANGUAGE_CLASSIFICATION_PROMPT.format(text=s)
}
]
prompt_text = tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True
)
retry_formatted_prompts.append(prompt_text)
# Generate with slightly higher temperature to encourage different outputs
retry_params = SamplingParams(
temperature=min(temperature * (1 + retry * 0.1), 0.5), # Gradually increase temp
max_tokens=max_tokens,
top_p=0.8,
top_k=20,
skip_special_tokens=False,
)
retry_outputs = llm.generate(retry_formatted_prompts, retry_params)
# Track newly successful and still-failed indices
new_failed_indices = []
for idx, sentence, output in zip(failed_indices, retry_sentences, retry_outputs):
response = output.outputs[0].text
final_answer = parse_final_answer(response)
if final_answer:
results[sentence] = (response, final_answer)
else:
new_failed_indices.append(idx)
newly_successful = len(failed_indices) - len(new_failed_indices)
print(f" ✓ {newly_successful} more samples successful")
num_labeled = sum(1 for s in sentences if s in results)
print(f" Total successful: {num_labeled}/{len(sentences)} ({num_labeled/len(sentences)*100:.2f}%)")
failed_indices = new_failed_indices
# Save results
print(f"\nSaving results to {output_file}...")
with open(output_file, "w", encoding="utf-8") as f:
for sentence in sentences:
if sentence in results:
_, final_answer = results[sentence]
data = {
"messages": [
{
"role": "user",
"content": sentence,
},
{
"role": "assistant",
"content": final_answer,
},
]
}
f.write(json.dumps(data, ensure_ascii=False) + "\n")
# Final report
print(f"\n{'='*60}")
print("DATA GENERATION COMPLETE")
print(f"{'='*60}")
num_labeled = sum(1 for s in sentences if s in results)
print(f"Total sentences: {len(sentences)}")
print(f"Valid labels generated: {num_labeled}")
print(f"Failed after {max_retries} retries: {len(failed_indices)}")
print(f"Final success rate: {num_labeled/len(sentences)*100:.2f}%")
print(f"Saved to: {output_file}")
if failed_indices:
print(f"\n⚠️ Warning: {len(failed_indices)} sentences failed to generate valid labels")
print("Consider inspecting these samples or adjusting the prompt/temperature")
def main():
parser = argparse.ArgumentParser(
description="用教师模型(长提示 + 思考)生成 Prompt 蒸馏训练数据",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument(
"--input_file",
type=str,
default="./example-data/multilingual.txt",
help="输入文本文件路径(每行一句待分类文本)",
)
parser.add_argument(
"--output_file",
type=str,
default="./data/prompt_distillation_lang.jsonl",
help="生成的训练数据保存路径(JSONL 格式)",
)
parser.add_argument(
"--model_name",
type=str,
default="Qwen/Qwen3-30B-A3B-Thinking-2507",
help="教师模型名称(用思考型模型以获得更高准确率)",
)
parser.add_argument(
"--temperature",
type=float,
default=0.15,
help="采样温度(与 tinker 保持一致,取 0.15",
)
parser.add_argument(
"--max_tokens",
type=int,
default=4096,
help="单条生成的最大 token 数",
)
parser.add_argument(
"--tensor_parallel_size",
type=int,
default=1,
help="张量并行使用的 GPU 数(30B 模型在 H100 上建议 2-4",
)
parser.add_argument(
"--max_retries",
type=int,
default=3,
help="失败样本的最大重试次数",
)
args = parser.parse_args()
# Create output directory if needed
output_dir = os.path.dirname(args.output_file)
if output_dir:
os.makedirs(output_dir, exist_ok=True)
# Check if input file exists
if not os.path.exists(args.input_file):
raise FileNotFoundError(f"Input file not found: {args.input_file}")
# Generate data
asyncio.run(
generate_distillation_data(
input_file=args.input_file,
output_file=args.output_file,
model_name=args.model_name,
temperature=args.temperature,
max_tokens=args.max_tokens,
tensor_parallel_size=args.tensor_parallel_size,
max_retries=args.max_retries,
)
)
if __name__ == "__main__":
main()