Review · updated OCT 11

Qwen3.5-27B review: a solid all-rounder

It scored 88 out of 100, #8 of 56. It solved 23 of 30 coding jobs and scored 100 on reading documents. Runs on a 24 GB graphics card or a Mac with 32 GB.

The short version
  • Qwen3.5-27B is a free model from Alibaba's Qwen team that you can run on your own computer. In our tests it's a solid all-rounder: 88 out of 100, #8 of 56.
  • It solved 23 of 30 coding jobs and scored 100 on reading documents. On our hardest tasks it scored 78.
  • Runs on a 24 GB graphics card or a Mac with 32 GB.

Coding

Our coding test is 30 programming jobs, from small ones like reading time durations or cleaning up messy data to harder ones like a config-file parser or a double-entry ledger. We run each answer against tests the model never sees, and a job only counts if everything passes. Qwen3.5-27B got 23 of 30 right. The best local coders solved 29 of 30.

Reading documents

The second test hands the model things like an expense claim thread, a pay stub or an insurance statement, and asks for specific numbers and dates. Many questions need a bit of math, or noticing a correction further down the email. Qwen3.5-27B scored 100, the best result we've seen.

TestScorePublic questionsSecret questions
Coding778674
Reading documents10098100
Decisions100100100

On the 18 hardest tasks (included in the scores above) it scored 78. This number separates the top models.

We tested the full-size version online. The smaller versions you download usually score a little lower.

Can your computer run it?

You can download Qwen3.5-27B in several sizes. Smaller versions fit on more computers and lose a little quality, so pick the biggest one that fits.

Your computerRuns it?Version to download
Graphics card, 8 GBNo–
Graphics card, 12 GBNo–
Graphics card, 16 GBNo–
Graphics card, 24 GBYesQ5
Graphics card, 32 GBYesQ6
Mac, 16 GBNo–
Mac, 24 GBNo–
Mac, 32 GBYesQ4
Mac, 48 GBYesQ8
Mac, 64 GBYesQ8
Mac, 96 GBYesQ8
Mac, 128 GBYesQ8

For normal-length chats. Long documents need more memory. Q4 or Q8? · How we worked this out

How to try it

Search for "Qwen3.5-27B GGUF" in LM Studio or on Hugging Face and pick the version that fits your computer. New to this? Start with our Ollama guide.

Want the strongest all-rounder instead? Qwen3.6 27B scored 97.

Its actual answers

What Qwen3.5-27B wrote for our public questions, word for word.

Coding: INI parser (hard) Didn't work
```python
import re
from typing import Optional


def parse_ini(text: str) -> dict[str, dict[str, str]]:
    lines = text.split('\n')
    sections: dict[str, dict[str, str]] = {}
    current_section: Optional[str] = None
    last_key: Optional[str] = None
    i = 0

    while i < len(lines):
        line = lines[i]

        # Check for continuation line
        if line and line[0] in (' ', '\t') and last_key is not None and current_section is not None:
            stripped = line.strip()
            if stripped:
                sections[current_section][last_key] += '\n' + stripped
            i += 1
            continue

        stripped_line = line.strip()

        # Skip empty lines
        if not stripped_line:
            i += 1
            continue

        # Skip comment lines
        if stripped_line[0] in (';', '#'):
            i += 1
            continue

        # Check for section header
        if stripped_line.startswith('[') and ']' in stripped_line:
            bracket_end = stripped_line.index(']')
            section_name = stripped_line[1:bracket_end].strip()
            current_section = section_name
            if current_section not in sections:
                sections[current_section] = {}
            last_key = None
            i += 1
            continue

        # Check for key-value pair
        sep_pos = -1
        for j, char in enumerate(stripped_line):
            if char in ('=', ':'):
                sep_pos = j
                break

        if sep_pos == -1:
            raise ValueError(f"Malformed line: {line}")

        if current_section is None:
            raise ValueError("Key before any section header")

        key = stripped_line[:sep_pos].strip().lower()
        value_part = stripped_line[sep_pos + 1:]

        value = _process_value(value_part)

        sections[current_section][key] = value
        last_key = key

        i += 1

    return _interpolate_sections(sections)


def _process_value(value_part: str) -> str:
    value_part = value_part.lstrip()

    if not value_part:
        return ''

    if value_part.startswith('"'):
        i = 1
        while i < len(value_part):
            if value_part[i] == '"':
                return value_part[1:i]
            elif value_part[i] == '\\' and i + 1 < len(value_part):
                i += 2
            else:
                i += 1
        return value_part[1:]

    result = []
    i = 0
    while i < len(value_part):
        if value_part[i] in (' ', '\t') and i + 1 < len(value_part):
            if value_part[i + 1] in (';', '#'):
                break
        result.append(value_part[i])
        i += 1

    return ''.join(result).strip()


def _interpolate_sections(sections: dict[str, dict[str, str]]) -> dict[str, dict[str, str]]:
    result: dict[str, dict[str, str]] = {}
    default_keys = sections.get('DEFAULT', {})

    for section_name, section_data in sections.items():
        if section_name == 'DEFAULT':
            continue

        merged = {**default_keys, **section_data}
        interpolated: dict[str, str] = {}
        for key, value in merged.items():
            interpolated[key] = _interpolate_value(value, section_name, merged, set(), sections)
        result[section_name] = interpolated

    return result


def _interpolate_value(
    value: str,
    section_name: str,
    all_values: dict[str, str],
    visiting: set[tuple[str, str]],
    sections: dict[str, dict[str, str]]
) -> str:
    result = []
    i = 0

    while i < len(value):
        if value[i:i+2] == '${':
            end = value.find('}', i + 2)
            if end == -1:
                raise ValueError(f"Unclosed interpolation: {value}")

            ref = value[i + 2:end]

            if ':' in ref:
                ref_parts = ref.split(':', 1)
                ref_section = ref_parts[0]
                ref_key = ref_parts[1].lower()

                if ref_section not in sections:
                    raise KeyError(f"Section '{ref_section}' not found")

                target_section = sections[ref_section]
                if ref_key not in target_section:
                    raise KeyError(f"Key '{ref_key}' not found in section '{ref_section}'")

                cycle_key = (ref_section, ref_key)
                if cycle_key in visiting:
                    raise ValueError(f"Interpolation cycle detected: {ref_section}:{ref_key}")

                visiting.add(cycle_key)
                try:
                    substituted = _interpolate_value(
                        target_section[ref_key],
                        ref_section,
                        target_section,
                        visiting,
                        sections
                    )
                finally:
                    visiting.discard(cycle_key)

                result.append(substituted)
            else:
                ref_key = ref.lower()

                if ref_key not in all_values:
                    raise KeyError(f"Key '{ref_key}' not found")

                cycle_key = (section_name, ref_key)
                if cycle_key in visiting:
                    raise ValueError(f"Interpolation cycle detected: {section_name}:{ref_key}")

                visiting.add(cycle_key)
                try:
                    substituted = _interpolate_value(
                        all_values[ref_key],
                        section_name,
                        all_values,
                        visiting,
                        sections
                    )
                finally:
                    visiting.discard(cycle_key)

                result.append(substituted)

            i = end + 1
        else:
            result.append(value[i])
            i += 1

    return ''.join(result)
```
Coding: TTL LRU (hard) Worked
```python
from collections import OrderedDict
from typing import Any, Callable, Optional


class TTLCache:
    def __init__(self, capacity: int, ttl: float, clock: Callable[[], float]):
        self.capacity = capacity
        self.ttl = ttl
        self.clock = clock
        self._cache: OrderedDict[Any, tuple[Any, float]] = OrderedDict()
        self._hits = 0
        self._misses = 0
        self._evictions = 0
        self._expirations = 0

    def _remove_expired(self) -> int:
        """Remove all expired entries, return count of removed entries."""
        now = self.clock()
        expired_keys = [key for key, (value, expiry) in self._cache.items() if now >= expiry]
        for key in expired_keys:
            del self._cache[key]
            self._expirations += 1
        return len(expired_keys)

    def put(self, key: Any, value: Any) -> None:
        if self.capacity == 0:
            return

        now = self.clock()
        expiry = now + self.ttl

        if key in self._cache:
            # Update existing entry: refresh expiry and recency
            self._cache[key] = (value, expiry)
            self._cache.move_to_end(key)
        else:
            # New entry: first remove expired entries
            self._remove_expired()

            # If still at capacity, evict LRU
            if len(self._cache) >= self.capacity:
                self._cache.popitem(last=False)
                self._evictions += 1

            # Insert new entry
            self._cache[key] = (value, expiry)

    def get(self, key: Any, default: Any = None) -> Any:
        if self.capacity == 0:
            self._misses += 1
            return default

        now = self.clock()

        if key not in self._cache:
            self._misses += 1
            return default

        value, expiry = self._cache[key]

        if now >= expiry:
            # Entry expired: remove and count as expiration + miss
            del self._cache[key]
            self._expirations += 1
            self._misses += 1
            return default

        # Entry is valid: mark as MRU and count as hit
        self._cache.move_to_end(key)
        self._hits += 1
        return value

    def __len__(self) -> int:
        if self.capacity == 0:
            return 0

        now = self.clock()
        count = 0
        for key, (value, expiry) in self._cache.items():
            if now < expiry:
                count += 1
        return count

    def stats(self) -> dict:
        return {
            "hits": self._hits,
            "misses": self._misses,
            "evictions": self._evictions,
            "expirations": self._expirations
        }
```
Coding: Parse duration Worked
```python
import re

def parse_duration(s: str) -> int:
    # Strip leading and trailing whitespace
    s = s.strip()
    
    # Check for empty or blank string
    if not s:
        raise ValueError("Empty or blank string")
    
    # Pattern to match a non-negative integer followed by a unit (h, m, s)
    part_pattern = r'(\d+)([hmsHMS])'
    
    # Find all number-unit pairs
    matches = re.findall(part_pattern, s)
    
    if not matches:
        raise ValueError("No valid duration parts found")
    
    # Verify that the entire string consists only of valid parts and whitespace
    # Build a pattern that allows optional whitespace between parts
    full_pattern = r'^\s*(\d+[hmsHMS]\s*)+$'
    if not re.fullmatch(full_pattern, s):
        raise ValueError("Invalid duration format")
    
    # Track seen units and their order
    seen_units = set()
    unit_order = ['h', 'm', 's']
    last_unit_index = -1
    total_seconds = 0
    
    for number_str, unit in matches:
        unit_lower = unit.lower()
        
        # Check for repeated units
        if unit_lower in seen_units:
            raise ValueError(f"Repeated unit: {unit}")
        
        # Check unit order (must be h, then m, then s)
        current_index = unit_order.index(unit_lower)
        if current_index < last_unit_index:
            raise ValueError(f"Units out of order: {unit}")
        
        seen_units.add(unit_lower)
        last_unit_index = current_index
        
        # Convert to seconds
        number = int(number_str)
        if unit_lower == 'h':
            total_seconds += number * 3600
        elif unit_lower == 'm':
            total_seconds += number * 60
        elif unit_lower == 's':
            total_seconds += number
    
    return total_seconds
```
Coding: Summarize ranges Worked
```python
def summarize_ranges(nums: list[int]) -> str:
    if not nums:
        return ""

    # Sort and remove duplicates
    unique_nums = sorted(set(nums))
    
    if not unique_nums:
        return ""

    result = []
    start = unique_nums[0]
    prev = unique_nums[0]

    for curr in unique_nums[1:]:
        if curr == prev + 1:
            prev = curr
        else:
            # Process the completed run
            if prev - start >= 2:
                result.append(f"{start}..{prev}")
            else:
                result.extend(str(x) for x in range(start, prev + 1))
            start = curr
            prev = curr

    # Process the final run
    if prev - start >= 2:
        result.append(f"{start}..{prev}")
    else:
        result.extend(str(x) for x in range(start, prev + 1))

    return ",".join(result)
```
Coding: Top customers Worked
```python
from collections import defaultdict

def top_customers(rows: list[dict], n: int) -> list[tuple[str, float]]:
    def parse_amount(amount_str):
        if not isinstance(amount_str, str):
            raise ValueError("Amount must be a string")
        
        s = amount_str.strip()
        if not s:
            raise ValueError("Empty amount string")
        
        is_negative = s.startswith('-')
        s = s.lstrip('-')
        s = s.replace('$', '').replace(',', '')
        
        try:
            value = float(s)
        except ValueError:
            raise ValueError(f"Cannot parse amount: {amount_str}")
        
        return -value if is_negative else value
    
    customer_totals = defaultdict(float)
    customer_names = {}
    
    for row in rows:
        customer_name = row.get("customer", "")
        
        if not customer_name or not str(customer_name).strip():
            continue
        
        trimmed_name = str(customer_name).strip()
        lower_name = trimmed_name.lower()
        
        if lower_name not in customer_names:
            customer_names[lower_name] = trimmed_name
        
        amount_str = row.get("amount", "")
        try:
            amount = parse_amount(amount_str)
        except (ValueError, TypeError):
            continue
        
        customer_totals[lower_name] += amount
    
    results = []
    for lower_name, total in customer_totals.items():
        name = customer_names[lower_name]
        rounded_total = round(total, 2)
        results.append((name, rounded_total))
    
    results.sort(key=lambda x: (-x[1], x[0]))
    
    return results[:n]
```
Coding: Slugify Worked
```python
import unicodedata
import re

def slugify(title: str, max_len: int = 50) -> str:
    # 1. Transliterate accented characters to ASCII and drop any other non-ASCII characters
    # NFKD normalization decomposes characters (e.g., 'é' -> 'e' + combining accent)
    # Encoding to ASCII with 'ignore' removes the combining marks and any remaining non-ASCII
    slug = unicodedata.normalize('NFKD', title)
    slug = slug.encode('ascii', 'ignore').decode('ascii')

    # 2. Lowercase
    slug = slug.lower()

    # 3. Replace every run of characters other than a-z and 0-9 with a single "-"
    # and strip leading/trailing "-"
    slug = re.sub(r'[^a-z0-9]+', '-', slug)
    slug = slug.strip('-')

    # 4. If the slug is longer than max_len, shorten it without cutting a word
    if len(slug) <= max_len:
        return slug

    words = slug.split('-')
    result_words = []
    current_length = 0

    for word in words:
        # Calculate length including separator if not the first word
        sep_len = 1 if result_words else 0
        new_length = current_length + sep_len + len(word)

        if new_length <= max_len:
            result_words.append(word)
            current_length = new_length
        else:
            break
    
    if result_words:
        final_slug = '-'.join(result_words)
    else:
        # If even the first word is longer than max_len, hard-cut it to max_len characters
        if words:
            final_slug = words[0][:max_len]
        else:
            final_slug = ""

    # 5. The result never ends with "-"
    final_slug = final_slug.rstrip('-')

    return final_slug
```
Coding: Token bucket Worked
```python
from typing import Callable

class TokenBucket:
    def __init__(self, capacity: float, refill_per_sec: float, clock: Callable[[], float]):
        self._capacity = capacity
        self._refill_per_sec = refill_per_sec
        self._clock = clock
        self._tokens = capacity
        self._last_refill_time = clock()

    def _refill(self) -> None:
        now = self._clock()
        elapsed = now - self._last_refill_time
        if elapsed > 0:
            added = elapsed * self._refill_per_sec
            self._tokens = min(self._capacity, self._tokens + added)
            self._last_refill_time = now

    def allow(self, cost: float = 1) -> bool:
        self._refill()
        if self._tokens >= cost:
            self._tokens -= cost
            return True
        return False

    @property
    def tokens(self) -> float:
        self._refill()
        return self._tokens
```
Decisions: Refund window (hard) 100% right
{
  "outcome": {
    "full_refund": 0.0,
    "partial_refund": 0.0,
    "store_credit": 0.99,
    "nothing": 0.01
  },
  "defective": {
    "true": 0.01,
    "false": 0.99
  }
}
Decisions: Incident matrix (hard) 100% right
{
  "severity": {
    "0": 0.005,
    "1": 0.985,
    "2": 0.005,
    "3": 0.005
  },
  "page": {
    "true": 0.01,
    "false": 0.99
  }
}
Decisions: Tool followup (hard) 100% right
{
  "tool": {
    "web_search": 0.002,
    "calculator": 0.003,
    "calendar": 0.98,
    "email": 0.005,
    "none": 0.01
  },
  "confirm": {
    "true": 0.95,
    "false": 0.05
  }
}
Decisions: Legit security alert (hard) 100% right
{
  "phishing": {
    "true": 0.01,
    "false": 0.99
  },
  "action_needed": {
    "true": 0.02,
    "false": 0.98
  }
}
Decisions: Meeting slot (hard) 100% right
{
  "slot": {
    "A": 0.0,
    "B": 1.0,
    "C": 0.0,
    "D": 0.0
  },
  "raj_last": {
    "true": 1.0,
    "false": 0.0
  }
}
Decisions: Review mixed (hard) 100% right
{
  "hardware": {
    "true": 0.99,
    "false": 0.01
  },
  "support": {
    "true": 0.99,
    "false": 0.01
  }
}
Decisions: Support checkout down 100% right
{
  "department": {
    "billing": 0.1,
    "technical": 0.9,
    "account": 0.0,
    "shipping": 0.0,
    "sales": 0.0
  },
  "urgency": {
    "0": 0.0,
    "1": 0.0,
    "2": 0.01,
    "3": 0.99
  },
  "outage": {
    "true": 0.99,
    "false": 0.01
  }
}
Decisions: Refund wrong plan 100% right
{
  "department": {
    "billing": 0.99,
    "technical": 0.0025,
    "account": 0.0025,
    "shipping": 0.0025,
    "sales": 0.0025
  },
  "refund": {
    "true": 0.99,
    "false": 0.01
  },
  "tone": {
    "frustrated": 0.01,
    "calm": 0.99
  }
}
Decisions: Moderation doxxing 100% right
{
  "policy": {
    "none": 0.005,
    "harassment": 0.99,
    "hate": 0.002,
    "spam": 0.002,
    "self_harm": 0.001
  },
  "personal_info": {
    "true": 0.995,
    "false": 0.005
  }
}
Decisions: Route calendar 100% right
{
  "tool": {
    "web_search": 0.01,
    "calculator": 0.00,
    "calendar": 0.95,
    "email": 0.03,
    "none": 0.01
  },
  "confirm": {
    "true": 0.98,
    "false": 0.02
  }
}
Decisions: Doc invoice missing due 100% right
{
  "doc_type": {
    "invoice": 0.99,
    "resume": 0.0,
    "contract": 0.0,
    "bank_statement": 0.0,
    "other": 0.01
  },
  "missing_due_date": {
    "true": 0.99,
    "false": 0.01
  }
}
Decisions: Phishing paypal 100% right
{
  "phishing": {
    "true": 0.999,
    "false": 0.001
  },
  "risk": {
    "0": 0.001,
    "1": 0.001,
    "2": 0.001,
    "3": 0.997
  }
}
Decisions: Pii ssn email 100% right
{
  "data_kind": {
    "none": 0.0,
    "contact": 0.01,
    "financial": 0.01,
    "government_id": 0.98,
    "health": 0.0
  },
  "sensitive": {
    "true": 0.99,
    "false": 0.01
  }
}
Decisions: Review mixed 100% right
{
  "sentiment": {
    "positive": 0.01,
    "neutral": 0.01,
    "negative": 0.98
  },
  "defect": {
    "true": 0.99,
    "false": 0.01
  },
  "recommend": {
    "true": 0.01,
    "false": 0.99
  }
}
Documents: Saas escalator (hard) 100% right
{
  "year2_price_per_seat_month": 47.25,
  "year3_price_per_seat_month": 47.25,
  "year1_invoice": 58320.00,
  "year2_invoice": 61236.00,
  "addon_months_billed": 6,
  "addon_invoice": 38556.00,
  "year3_invoice": 134946.00,
  "year3_discount_percent": 15,
  "total_contract_value": 293058.00,
  "contract_end_date": "2027-02-28"
}
Documents: Expense thread 96% right
{
  "employee_id": "EMP-20417",
  "destination_city": "Lisbon",
  "trip_start": "2025-02-24",
  "trip_end": "2025-02-27",
  "approved_items": [
    {
      "date": "2025-02-24",
      "category": "airfare",
      "amount_usd": 1184.60
    },
    {
      "date": "2025-02-24",
      "category": "ground_transport",
      "amount_usd": 38.88
    },
    {
      "date": "2025-02-25",
      "category": "meals",
      "amount_usd": 229.39
    },
    {
      "date": "2025-02-26",
      "category": "lodging",
      "amount_usd": 466.56
    },
    {
      "date": "2025-02-27",
      "category": "ground_transport",
      "amount_usd": 44.82
    }
  ],
  "rejected_item_count": 1,
  "per_diem_days": 3,
  "per_diem_usd": 65.00,
  "total_reimbursable_usd": 2159.25,
  "approver_email": "priya.raman@corvane.com"
}
Documents: Lease amendment 100% right
{
  "tenants": ["Marcus Lin", "Sofia Lin"],
  "landlord": "Ridgeline Property Group LLC",
  "zip": "97205",
  "lease_end": "2025-11-30",
  "original_monthly_rent": 2150.00,
  "monthly_rent_from_2025_06_01": 2236.00,
  "late_fee_from_2025_06_01": 111.80,
  "security_deposit": 2150.00,
  "total_pet_deposits": 800.00,
  "total_monthly_payment_july_2025": 2306.00,
  "move_in_payment": 4700.00
}
Documents: Ticket SLA 92% right
{
  "ticket_id": "48213",
  "account_id": "ACC-7731",
  "open_issue": "inventory_sync",
  "resolved_issues": [
    "billing_address",
    "invoice_pdf"
  ],
  "affected_orders": [
    "SO-99812",
    "SO-99820",
    "SO-99827"
  ],
  "priority": "P2",
  "sla_due_local": "2025-09-15T15:30",
  "sla_due_utc": "2025-09-15T20:30:00Z",
  "reissued_invoice": "INV-2025-0812"
}
Documents: Sales footnotes 100% right
{
  "q3_total_usd": 15346000,
  "q2_total_usd": 14464000,
  "q2_central_originally_reported_usd": 3047000,
  "q2_to_q3_change_pct": 6.1,
  "top_region_q3": "East",
  "fastest_growing_region_q1_to_q3": "International",
  "regions_declining_q2_to_q3": [
    "East"
  ],
  "international_q3_organic_usd": 1731000,
  "west_excluding_mountain_q3_usd": 4201000
}

Size: 28B parameters. First tested OCT 10.

Models that scored about the same

Comments

Sign in with GitHub to comment. Spam and abuse are hidden automatically.